SLOPSHOPPER

allowlist-coach

Counts permission dialogs per rule and, after repeated approvals, offers to add the rule to permissions.allow, asking before it writes

newpaneguardcommandtoast
★ 2v0.3.0MITupdated 2026-10-08ice-lfernandes/claude-code-mods/allowlist-coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · allowlist-coach
│ ┃ Allowlist ✕ › fix the failing auth test and add an audit log call │ ┃ Permission dialogs answered in /work/app. │ ┃ A rule is offered after 5 approvals and no ⏺ Read(src/auth.ts) │ ┃ refusal. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No dialogs answered in this project yet. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ /allowlist allow · dismiss · remove · reset ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /allowlist │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Allowlist
Permission dialogs answered in /work/app. A rule is offered after 5 approvals and no refusal. No dialogs answered in this project yet. /allowlist allow · dismiss · remove · reset · help [ Close
README

allowlist-coach

Counts the permission dialogs you answer, per rule, and offers the rules you keep approving. It writes nothing until you pick a file to add the rule to in a dialog.

allowlist-coach: the line under the permission dialog, the toast and the /allowlist pane

  • Under the dialog: one line with your approvals of that rule in this project toward the offer, ●●●○○ 3/5, and how many are left.
  • Toast, once per rule: "You approved Bash(./mvnw test:*) 5 times here. /allowlist to add it to permissions.allow."
  • /allowlist opens a pane with every rule, numbered: ready ones first, then by approvals. Each rule shows its status, approvals and refusals (or ●●●○○ 3/5 while it counts), and under it the last call that asked and when (e.g. ./mvnw test -pl core · 12 min ago). A risky rule says why: (runs rm), (the whole tool), (a bare wildcard).
  • Tabs (all · ready · counting · refused, each with its count) and a filter that matches the rule or the call that asked narrow the list. The numbers stay those of the whole list, so /allowlist allow 3 names the same rule whatever shows. The list scrolls with the wheel or ▲ up / ▼ down, under a 11–20 of 34 range.
  • Actions on each rule: allow on a ready rule; dismiss and reset count on the others; remove from allow on a rule the coach added.
  • allow asks first, and shows the line it adds. The answers: add to .claude/settings.local.json (just you), add to .claude/settings.json (shared with the team, committed with the code), Not now, or Never offer it. The rest of the file stays as it was.
  • remove from allow asks first, with Cancel first, then takes the rule out of the file the coach added it to. The rule is then dismissed, so it is not offered again.
  • Commands: /allowlist allow 1, dismiss 1 and remove 1 take the pane's number or the rule written out. /allowlist reset 1 sets one rule's count back to zero; /allowlist reset clears this project's counts. Both ask first, with Cancel first, so a stray Enter keeps them. /allowlist help lists them.
  • The pane's footer has the same verbs as buttons. allow, dismiss, remove and reset put the command in the prompt for you to finish and send; help answers at once.

Options

In /config:

OptionDefaultWhat it does
languageautopt-BR or en for the pane, the line under the dialog, the questions and the toasts. auto follows LANG: Portuguese for pt_*, English otherwise
threshold5Approvals, with no refusal, before a rule is offered: 2 to 20

The rule is the one the engine itself suggests for "Yes, and don't ask again" (so ./mvnw test -Dtest=A and ./mvnw test -Dtest=B count as one Bash(./mvnw test:*)). When the engine suggests none, the rule is the exact command, or WebFetch(domain:<host>).

What it never offers

  • A rule you refused even once, however many times you approved it.
  • A whole tool (Bash, Edit, Write, Read, WebFetch) or a bare wildcard (Bash(*), Read(/**)).
  • A command that deletes, escalates, reaches the network or runs arbitrary code: rm, sudo, chmod, curl, ssh, git push, git reset, bash -c, node, … | sh, --force, and the like. These are counted and shown as risky.
  • A rule you already put under permissions.ask or permissions.deny.
  • A rule you dismissed.

Dialogs that a settings hook answered, and decisions made in auto, dontAsk or bypassPermissions mode, are not counted: they are not your answer.

Install

/plugin install allowlist-coach --marketplace ice-lfernandes/claude-code-mods

Or for one session: claude --plugin-dir ./allowlist-coach

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
allowlist-coachNoNoReads and writes .claude/settings.local.json, or .claude/settings.json when you pick it, after you confirmNoNo

It keeps the counts in its plugin store, per project root, across sessions: the rule, approvals, refusals, the last command or path that asked, and when. Up to 200 rules per project. It reads LANG for the auto language.

Source 5 files
hooks/register.tsx 444 lines
1// allowlist-coach: counts permission dialogs per rule and offers the ones you keep approving.
2//
3//   counting  tool.call opens a record for each call; classic.PermissionRequest marks the ones
4//             whose dialog the person answers (a classic hook's own decision, or auto mode,
5//             is not the person); classic.PostToolUse(Failure) marks the ones that ran. When
6//             the call resolves, an asked call that ran is an approval, one that did not, a
7//             refusal. Counts live in $.store per project root, across sessions.
8//   dialog    a line under the open dialog: approvals so far, `●●●○○ 3/5`, toward the offer.
9//   toast     once, when a rule reaches `threshold` approvals (5 by default) with no refusal.
10//   /allowlist  pane with every rule, numbered, ready ones first, each with the last call that
11//             asked and when, and its actions: allow, dismiss, reset count, remove from allow.
12//             Tabs and a filter narrow the list; it scrolls. /allowlist allow <n> | dismiss <n>
13//             | remove <n> | reset [<n>] | help, where <n> is the pane's number or the rule.
14//
15// It writes the project's .claude/settings.local.json, or its .claude/settings.json when the
16// person picks that file, and only after the person picks one in a dialog. It takes a rule
17// out of allow only after the person picks "Remove". Reset clears counts only after the person
18// picks "Clear" or "Reset". It never offers a rule that is a whole tool, a bare wildcard, or a
19// command that deletes, escalates or reaches the network (tally.riskOf), and never one the
20// person refused or put under ask or deny.
21
22import { atom, read, update } from 'claude-code'
23import type { EngineInterface, Register } from 'claude-code'
24
25import type { Configured, Entry, Tab } from '../types'
26import {
27  addAllow,
28  exampleOf,
29  keyAt,
30  keyOf,
31  noticeFor,
32  progress,
33  record,
34  removeAllow,
35  riskOfEntry,
36  rulesFor,
37  setState,
38  setThreshold,
39  SETTINGS_FILE,
40  SHARED_FILE,
41  shows,
42  sorted,
43  stable,
44  status,
45  summary,
46  TABS,
47  thresholdNow,
48  toConfigured,
49  zero,
50} from './tally'
51import type { Lang, Verb } from './ui'
52import { clip, fillArgs, langOf, linesOf, verbRow, windowOf } from './ui'
53import { COMMAND, WORDS } from './words'
54
55const PANE = 'allowlist'
56/** Modes where a dialog is not the person's answer, or is not shown at all. */
57const NOT_THE_PERSON = new Set(['auto', 'dontAsk', 'bypassPermissions'])
58
59const entries = atom({ plugin: 'allowlist-coach', key: 'entries' } as const, {} as Record<string, Entry>)
60const configured = atom({ plugin: 'allowlist-coach', key: 'configured' } as const, { allow: [], ask: [], deny: [] } as Configured)
61const offset = atom({ plugin: 'allowlist-coach', key: 'offset' } as const, 0)
62const tab = atom({ plugin: 'allowlist-coach', key: 'tab' } as const, 'all' as Tab)
63const filter = atom({ plugin: 'allowlist-coach', key: 'filter' } as const, '')
64
65type Open = { tool: string; input: string; agentId?: string; rules: string[] | null; ran: boolean; example: string }
66const open = new Map<string, Open>()
67let root = ''
68// The last first row the list can start at, from the last render: the wheel stops there.
69let lastStart = 0
70// Set by register from the options, and by session.start from the system's LANG.
71let lang: Lang = 'en'
72
73const storeKey = () => `entries:${root}`
74
75const save = async ($: EngineInterface, change: (list: Record<string, Entry>) => Record<string, Entry>) => {
76  await update($, entries, change)
77  await $.store.set(storeKey(), await read($, entries))
78}
79
80const refresh = async ($: EngineInterface) => {
81  try {
82    const settings = await $.settings.read()
83    await update($, configured, () => toConfigured(settings as never))
84  } catch {
85    // Keep the lists read last.
86  }
87}
88
89const argsOf = (e: Record<string, unknown>) => {
90  const { tool, tool_use_id, agentId, consent, ...args } = e
91  return args
92}
93
94/** Asks with Cancel first, so a stray Enter changes nothing; true only on `yes`. */
95const confirm = async ($: EngineInterface, question: string, yes: string) => {
96  try {
97    return (await $.ui.ask(question, { header: 'allowlist', options: [WORDS[lang].cancel, yes] })) === yes
98  } catch {
99    return false
100  }
101}
102
103/** A settings file's text through `change`, written back unless `change` returns null. Throws as addAllow does. */
104const edit = async ($: EngineInterface, file: string, change: (text: string) => string | null) => {
105  const path = `${root}/${file}`
106  const text = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
107  const next = change(text)
108  if (next !== null) await $.fs.write(path, next)
109  return next !== null
110}
111
112/**
113 * Asks, showing the line it adds, then adds the entry's rules to settings.local.json or, when
114 * the person picks it, the shared settings.json. Returns what to tell the person.
115 */
116const allow = async ($: EngineInterface, key: string): Promise<string> => {
117  const w = WORDS[lang]
118  const entry = (await read($, entries))[key]
119  if (!entry) return w.noRule(key)
120  const what = entry.rules.join(' and ')
121  let answer: string
122  try {
123    answer = await $.ui.ask(`${w.askAdd(what, entry.approved)}\n${w.preview(entry.rules)}`, { header: 'allowlist', options: [w.addLocal, w.addShared, w.notNow, w.never] })
124  } catch {
125    return w.nothingChanged
126  }
127  if (answer === w.never) {
128    await save($, list => setState(list, key, 'dismissed'))
129    return w.neverAgain(what)
130  }
131  const file = answer === w.addLocal ? SETTINGS_FILE : answer === w.addShared ? SHARED_FILE : null
132  if (!file) return w.nothingChanged
133  try {
134    await edit($, file, text => addAllow(text, entry.rules, file))
135  } catch (error) {
136    return w.leftAsIs(file, error instanceof Error ? error.message : String(error))
137  }
138  await save($, list => setState(list, key, 'added', file))
139  await refresh($)
140  return w.added(what, file)
141}
142
143/** Asks, then takes the rules the coach added out of the file it added them to; the rule is not offered again. */
144const remove = async ($: EngineInterface, key: string): Promise<string> => {
145  const w = WORDS[lang]
146  const entry = (await read($, entries))[key]
147  if (!entry) return w.noRule(key)
148  if (entry.state !== 'added') return w.notAdded(key)
149  const what = entry.rules.join(' and ')
150  const file = entry.file ?? SETTINGS_FILE
151  if (!(await confirm($, w.askRemove(what, file), w.removeOpt))) return w.nothingChanged
152  let changed: boolean
153  try {
154    changed = await edit($, file, text => removeAllow(text, entry.rules, file))
155  } catch (error) {
156    return w.leftAsIs(file, error instanceof Error ? error.message : String(error))
157  }
158  if (!changed) return w.notInFile(what, file)
159  await save($, list => setState(list, key, 'dismissed'))
160  await refresh($)
161  return w.removed(what, file)
162}
163
164/** Asks, then sets one rule's counts back to zero. */
165const zeroOne = async ($: EngineInterface, key: string): Promise<string> => {
166  const w = WORDS[lang]
167  const entry = (await read($, entries))[key]
168  if (!entry) return w.noRule(key)
169  if (!(await confirm($, w.askZero(key, entry.approved, entry.denied), w.zeroOpt))) return w.nothingChanged
170  await save($, list => zero(list, key))
171  return w.zeroed(key)
172}
173
174const dismiss = async ($: EngineInterface, key: string) => {
175  const w = WORDS[lang]
176  if (!(await read($, entries))[key]) return w.noRule(key)
177  await save($, list => setState(list, key, 'dismissed'))
178  return w.neverAgain(key)
179}
180
181/** Asks, with Cancel first so a stray Enter keeps the counts, then clears them. */
182const reset = async ($: EngineInterface) => {
183  const w = WORDS[lang]
184  const n = Object.keys(await read($, entries)).length
185  if (n === 0) return w.noDialogs
186  if (!(await confirm($, w.askReset(n), w.clear))) return w.nothingChanged
187  await save($, () => ({}))
188  return w.cleared
189}
190
191/** /allowlist and its arguments: what the command answers, and what the pane's verbs run. */
192const runCommand = async ($: EngineInterface, args: string): Promise<{ text?: string }> => {
193  const w = WORDS[lang]
194  const [verb = '', ...rest] = args.trim().split(/\s+/)
195  const arg = rest.join(' ')
196  const key = async () => keyAt(await read($, entries), await read($, configured), arg)
197  switch (verb.toLowerCase()) {
198    case 'allow':
199      if (arg) return { text: await allow($, await key()) }
200      break
201    case 'dismiss':
202      if (arg) return { text: await dismiss($, await key()) }
203      break
204    case 'remove':
205      if (arg) return { text: await remove($, await key()) }
206      break
207    case 'reset':
208      return { text: arg ? await zeroOne($, await key()) : await reset($) }
209    case 'help':
210      return { text: w.help }
211    case '': {
212      await refresh($)
213      const opened = await $.ui.open({ id: PANE, title: w.pane, focus: true, closeOnEscape: true }).catch(() => null)
214      if (opened?.isPlaced) return {}
215      return { text: summary(await read($, entries), await read($, configured), lang) }
216    }
217  }
218  return { text: w.help }
219}
220
221/** Puts a text in the prompt for the person to send; runs nothing. */
222const fill = async ($: EngineInterface, text: string) => {
223  await $.prompt.fill(fillArgs(text))
224}
225
226/** The pane's verbs: those that take a number, and reset, wait in the prompt. */
227const verbs = (l: Lang): readonly Verb[] => {
228  const n = l === 'en' ? '[number]' : '[número]'
229  return [
230    { verb: 'allow', fill: `/${COMMAND} allow ${n}` },
231    { verb: 'dismiss', fill: `/${COMMAND} dismiss ${n}` },
232    { verb: 'remove', fill: `/${COMMAND} remove ${n}` },
233    { verb: 'reset', fill: `/${COMMAND} reset` },
234    { verb: 'help' },
235  ]
236}
237
238/** A verb pressed in the pane: fills the prompt, or runs and writes its answer to the transcript. */
239const pressVerb = async ($: EngineInterface, v: Verb) => {
240  try {
241    if (v.fill) return await fill($, v.fill)
242    const { text } = await runCommand($, v.verb)
243    for (const line of linesOf(text)) $.ui.log(line)
244  } catch {
245    $.ui.toast(WORDS[lang].failedToRun(`/${COMMAND} ${v.verb}`))
246  }
247}
248
249export const register: Register = (on, options) => {
250  lang = langOf(options.language)
251  setThreshold(options.threshold)
252
253  on('session.start', async ($, e, next) => {
254    const result = await next(e)
255    lang = langOf(options.language, await $.env.get('LANG').catch(() => undefined))
256    await $.command.register({
257      name: COMMAND,
258      description: WORDS[lang].description,
259      argumentHint: '[allow <n>|dismiss <n>|remove <n>|reset [<n>]|help]',
260      immediate: true,
261    })
262    root = await $.session.root()
263    const stored = (await $.store.get(storeKey())) as Record<string, Entry> | undefined
264    await update($, entries, () => stored ?? {})
265    await refresh($)
266    return result
267  })
268
269  on('tool.call', async ($, e, next) => {
270    const id = e.tool_use_id
271    if (!id) return next(e)
272    const input = argsOf(e as never)
273    open.set(id, { tool: e.tool, input: stable(input), agentId: e.agentId, rules: null, ran: false, example: exampleOf(e.tool, input) })
274    let result
275    try {
276      result = await next(e)
277    } catch (error) {
278      open.delete(id)
279      throw error
280    }
281    const call = open.get(id)
282    open.delete(id)
283    if (!call?.rules) return result
284    const key = keyOf(call.rules)
285    const was = (await read($, entries))[key]
286    const before = was ? status(was, await read($, configured)) : 'counting'
287    const now = await $.clock.now()
288    await save($, list => record(list, call.rules!, call.ran, call.example, now))
289    await refresh($)
290    const entry = (await read($, entries))[key]!
291    if (before !== 'ready' && entry.state === 'counting' && status(entry, await read($, configured)) === 'ready') {
292      await save($, list => setState(list, key, 'offered'))
293      $.ui.toast(WORDS[lang].offered(key, entry.approved), { timeoutMs: 10000 })
294    }
295    return result
296  }).catch(($, e, next) => next(e))
297
298  on('classic.PermissionRequest', async ($, e, next) => {
299    const result = await next(e)
300    if (result.decision) return result
301    if (e.permission_mode && NOT_THE_PERSON.has(e.permission_mode)) return result
302    const input = stable(e.tool_input)
303    for (const [id, call] of open) {
304      if (call.rules || call.tool !== e.tool_name || call.input !== input || call.agentId !== e.agent_id) continue
305      call.rules = rulesFor(e.tool_name, e.tool_input, e.permission_suggestions)
306      const text = noticeFor((await read($, entries))[keyOf(call.rules)], await read($, configured), lang)
307      if (text) {
308        try {
309          $.ui.notice(id, text)
310        } catch {
311          // No dialog open for it on this host.
312        }
313      }
314      break
315    }
316    return result
317  }).catch(($, e, next) => next(e))
318
319  on('classic.PostToolUse', async ($, e, next) => {
320    const call = open.get(e.tool_use_id)
321    if (call) call.ran = true
322    return next(e)
323  }).catch(($, e, next) => next(e))
324
325  on('classic.PostToolUseFailure', async ($, e, next) => {
326    const call = open.get(e.tool_use_id)
327    if (call) call.ran = true
328    return next(e)
329  }).catch(($, e, next) => next(e))
330
331  on('command.run', { command: COMMAND }, ($, e) => runCommand($, e.args))
332
333  // The wheel over the pane moves the list.
334  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
335    await update($, offset, o => Math.max(0, Math.min(lastStart, o + e.by)))
336    return {}
337  }).catch(($, e, next) => next(e))
338
339  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
340    const els = $.ui.resolve(e)
341    const { Box, Text, Button } = els
342    // The mobile app draws no field yet: there the list shows unfiltered.
343    const Input = 'Input' in els ? els.Input : null
344    const w = WORDS[lang]
345    const now = await $.clock.now()
346    const rows = sorted(await read($, entries), await read($, configured))
347    const width = Math.max(40, (e.props.bodyColumns || e.viewport?.columns || 80) - 2)
348    const picked = await read($, tab)
349    const query = Input ? await read($, filter) : ''
350    // Numbered over every rule, as /allowlist allow <n> reads the number, whatever shows.
351    const numbered = rows.map((r, i) => ({ ...r, n: i + 1 }))
352    const found = numbered.filter(r => shows(r, picked, query))
353    // Two lines a rule. The rest of the pane: hint, tabs, filter, scroll row, footer, gaps.
354    const fixed = 11
355    const listRows = Math.max(2, Math.floor(((e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 30) - fixed) / 2))
356    const view = windowOf(found.length, await read($, offset), listRows)
357    lastStart = Math.max(0, found.length - listRows)
358    const scrollList = (by: number) => update($, offset, o => windowOf(found.length, o + by, listRows).start)
359    const pickTab = async (t: Tab) => {
360      await update($, tab, () => t)
361      await update($, offset, () => 0)
362    }
363    const toast = (text: Promise<string>) => text.then(t => $.ui.toast(t))
364    const count = (t: Tab) => (t === 'all' ? rows.length : rows.filter(r => r.status === t).length)
365
366    return (
367      <Box flexDirection="column" paddingX={1} gap={1}>
368        <Text dimColor>{w.hint(root || '.', thresholdNow())}</Text>
369        {rows.length === 0 && <Text dimColor>{w.empty}</Text>}
370        {rows.length > 0 && (
371          <Box flexDirection="column">
372            <Box flexDirection="row" flexWrap="wrap" gap={2}>
373              {TABS.map(t => (
374                <Button key={`tab:${t}`} plain dimColor={t !== picked} label={`${t === picked ? '▸ ' : ''}${w.tabs[t]} ${count(t)}`} onPress={() => pickTab(t)} />
375              ))}
376            </Box>
377            {Input && (
378              <Input
379                key="filter"
380                label={w.filter}
381                placeholder={w.placeholder}
382                value={query}
383                onInput={async (value: string) => {
384                  await update($, filter, () => value)
385                  await update($, offset, () => 0)
386                }}
387                onSubmit={(value: string) => update($, filter, () => value)}
388              />
389            )}
390          </Box>
391        )}
392        {rows.length > 0 && (
393          <Box flexDirection="column">
394            {found.length === 0 && <Text dimColor>{w.noMatch}</Text>}
395            {found.slice(view.start, view.end).map(r => {
396              const risk = r.status === 'risky' ? riskOfEntry(r.entry) : null
397              const canDismiss = r.status === 'ready' || r.status === 'counting' || r.status === 'refused' || r.status === 'risky'
398              const hasCounts = r.entry.approved + r.entry.denied > 0 && r.entry.state !== 'added'
399              return (
400                <Box key={`row:${r.key}`} flexDirection="column">
401                  <Box flexDirection="row" gap={1}>
402                    <Text dimColor>{String(r.n).padStart(2)}</Text>
403                    <Text color={r.status === 'ready' ? 'success' : r.status === 'risky' || r.status === 'refused' ? 'warning' : undefined} dimColor={r.status !== 'ready' && r.status !== 'risky' && r.status !== 'refused'}>
404                      {w.status[r.status].padEnd(10)}
405                    </Text>
406                    {r.status === 'counting' ? (
407                      <Text color="claude">{progress(r.entry.approved)}</Text>
408                    ) : (
409                      <Text>
410                        <Text>{`✓${String(r.entry.approved).padStart(3)} `}</Text>
411                        <Text color={r.entry.denied > 0 ? 'warning' : undefined}>{`✗${String(r.entry.denied).padStart(3)}`}</Text>
412                      </Text>
413                    )}
414                    <Text wrap="truncate-end">{r.key}</Text>
415                    {risk && <Text color="warning" wrap="truncate-end">{`(${w.risk(risk)})`}</Text>}
416                    {r.status === 'ready' && <Button key={`allow-${r.n}`} label={w.allow} onPress={() => toast(allow($, r.key))} />}
417                  </Box>
418                  <Box flexDirection="row" gap={2}>
419                    <Text dimColor wrap="truncate-end">{`   ${clip(w.example(r.entry.example, w.ago(now - r.entry.lastAt)), Math.max(20, width - 44))}`}</Text>
420                    {canDismiss && <Button key={`dismiss-${r.n}`} label={w.dismiss} plain dimColor onPress={() => toast(dismiss($, r.key))} />}
421                    {hasCounts && <Button key={`zero-${r.n}`} label={w.resetCount} plain dimColor onPress={() => toast(zeroOne($, r.key))} />}
422                    {r.entry.state === 'added' && <Button key={`remove-${r.n}`} label={w.remove} plain dimColor onPress={() => toast(remove($, r.key))} />}
423                  </Box>
424                </Box>
425              )
426            })}
427            {found.length > listRows && (
428              <Box flexDirection="row" gap={2}>
429                <Button key="list:up" plain dimColor={view.start === 0} label={w.up} onPress={() => scrollList(-listRows + 1)} />
430                <Button key="list:down" plain dimColor={view.end === found.length} label={w.down} onPress={() => scrollList(listRows - 1)} />
431                <Text dimColor>{w.range(view.start + 1, view.end, found.length)}</Text>
432              </Box>
433            )}
434          </Box>
435        )}
436        <Box flexDirection="row" flexWrap="wrap" gap={2}>
437          {verbRow({ Box, Text, Button }, COMMAND, verbs(lang), v => pressVerb($, v))}
438          <Button key="close" role="dismiss" label={w.close} onPress={() => $.ui.close({ id: PANE })} />
439        </Box>
440      </Box>
441    )
442  })
443}
444
hooks/tally.ts 269 lines
1import type { Configured, Entry, Tab } from '../types'
2import type { Lang } from './ui'
3import { clip } from './ui'
4import { WORDS } from './words'
5
6/** Approvals, with no refusal, before the coach offers a rule: the default of the `threshold` option. */
7export const THRESHOLD = 5
8
9let threshold = THRESHOLD
10
11/** Sets the approvals an offer takes, from the `threshold` option: a whole number from 2 to 20. */
12export const setThreshold = (option: unknown) => {
13  const n = Math.round(Number(option))
14  threshold = Number.isFinite(n) && option !== undefined && option !== null && option !== '' ? Math.max(2, Math.min(20, n)) : THRESHOLD
15}
16
17/** The approvals an offer takes now. */
18export const thresholdNow = () => threshold
19/** Entries kept per project; the least recent go first. */
20export const ENTRIES_KEPT = 200
21
22export const SETTINGS_FILE = '.claude/settings.local.json'
23/** The project's shared settings, committed with the code. */
24export const SHARED_FILE = '.claude/settings.json'
25
26type Suggestion = { type: string; behavior?: string; rules?: readonly { toolName: string; ruleContent?: string }[] }
27
28export const formatRule = (toolName: string, content?: string) => (content ? `${toolName}(${content})` : toolName)
29
30const host = (url: unknown) => {
31  try {
32    return new URL(String(url)).hostname
33  } catch {
34    return ''
35  }
36}
37
38/**
39 * The rules that would have skipped this dialog: the engine's own suggestion when it made one
40 * (what "Yes, and don't ask again" would save), else a narrow rule of our own.
41 */
42export const rulesFor = (tool: string, input: unknown, suggestions?: readonly Suggestion[]): string[] => {
43  const offered = (suggestions ?? [])
44    .filter(s => s.type === 'addRules' && s.behavior === 'allow')
45    .flatMap(s => s.rules ?? [])
46    .map(r => formatRule(r.toolName, r.ruleContent))
47  if (offered.length > 0) return [...new Set(offered)]
48  const args = (input ?? {}) as Record<string, unknown>
49  if (tool === 'Bash' && typeof args.command === 'string') return [formatRule('Bash', args.command.trim())]
50  if (tool === 'WebFetch' && host(args.url)) return [formatRule('WebFetch', `domain:${host(args.url)}`)]
51  return [tool]
52}
53
54/** One short line for the call that asked. */
55export const exampleOf = (tool: string, input: unknown) => {
56  const args = (input ?? {}) as Record<string, unknown>
57  const text = String(args.command ?? args.file_path ?? args.notebook_path ?? args.url ?? args.pattern ?? args.query ?? tool)
58  const line = text.split('\n')[0]!.trim()
59  return line.length > 80 ? `${line.slice(0, 79)}…` : line
60}
61
62const BROAD_TOOLS = new Set(['Bash', 'PowerShell', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Read', 'Glob', 'Grep', 'WebFetch'])
63
64/** Why a rule is never offered: the whole tool, a bare wildcard, a risky command, or a rule it cannot read. */
65export type Risk = { kind: 'tool' } | { kind: 'wildcard' } | { kind: 'command'; what: string } | { kind: 'unreadable' }
66
67const RISKY_COMMAND = [
68  /^(sudo|su|doas)\b/,
69  /^(rm|rmdir|dd|mkfs\S*|shred|truncate|chmod|chown|kill|pkill|killall|shutdown|reboot)\b/,
70  /^(curl|wget|ssh|scp|rsync|nc|eval|exec|source)\b/,
71  /^(bash|sh|zsh|fish|python\d*|node|deno|bun|ruby|perl)(\s+-[ce]\b|\s*$|\s*:\*)/,
72  /^git\s+(push|reset|clean|rebase|filter-branch|update-ref)\b/,
73  /^git\s+(checkout|restore)\s+(--\s+)?\.\s*$/,
74  /^git\s+branch\s+-D\b/,
75  /^(docker|kubectl|helm|terraform|aws|gcloud|az)\b.*\b(rm|delete|destroy|prune|apply)\b/,
76  /^(npm|pnpm|yarn)\s+publish\b/,
77  /--force\b|\s-f\b.*\bpush\b/,
78  /\|\s*(sudo\s+)?(sh|bash|zsh)\b/,
79]
80
81/**
82 * Why a rule lets through more than one familiar command, or null when it does not: a whole
83 * tool, a bare wildcard, or a command that deletes, escalates, reaches the network or runs
84 * arbitrary code (`what` is the part that matched). Such a rule is counted and shown, never
85 * offered.
86 */
87export const riskOf = (rule: string): Risk | null => {
88  const match = /^([^(]+)(?:\((.*)\))?$/s.exec(rule)
89  if (!match) return { kind: 'unreadable' }
90  const [, toolName, content] = match
91  if (content === undefined) return BROAD_TOOLS.has(toolName!) ? { kind: 'tool' } : null
92  const body = content.trim()
93  if (body === '' || body === '*' || body === ':*' || body === '**' || body.startsWith('/**')) return { kind: 'wildcard' }
94  if (toolName !== 'Bash' && toolName !== 'PowerShell') return null
95  for (const re of RISKY_COMMAND) {
96    const hit = re.exec(body)
97    if (hit) return { kind: 'command', what: clip(hit[0].replace(/\s+/g, ' ').trim(), 24) }
98  }
99  return null
100}
101
102export const isRisky = (rule: string) => riskOf(rule) !== null
103
104/** The first risk among an entry's rules. */
105export const riskOfEntry = (entry: Entry) => entry.rules.map(riskOf).find(r => r !== null) ?? null
106
107const sameRule = (a: string, b: string) => a.replace(/\s+/g, ' ') === b.replace(/\s+/g, ' ')
108const listed = (list: readonly string[], rule: string) => list.some(r => sameRule(r, rule))
109
110export type Status = 'ready' | 'counting' | 'refused' | 'risky' | 'allowed' | 'pinned' | 'dismissed'
111
112/**
113 * Where an entry stands. `allowed`: every rule is in permissions.allow already. `pinned`: one
114 * sits in ask or deny, a choice the coach never argues with. `ready`: offer it.
115 */
116export const status = (entry: Entry, configured: Configured): Status => {
117  if (entry.state === 'added' || entry.rules.every(r => listed(configured.allow, r))) return 'allowed'
118  if (entry.rules.some(r => listed(configured.ask, r) || listed(configured.deny, r))) return 'pinned'
119  if (entry.state === 'dismissed') return 'dismissed'
120  if (entry.rules.some(isRisky)) return 'risky'
121  if (entry.denied > 0) return 'refused'
122  return entry.approved >= threshold ? 'ready' : 'counting'
123}
124
125export const keyOf = (rules: readonly string[]) => rules.join(', ')
126
127/** Counts one answered dialog; drops the least recent entries past ENTRIES_KEPT. */
128export const record = (
129  entries: Record<string, Entry>,
130  rules: readonly string[],
131  approved: boolean,
132  example: string,
133  now: number,
134): Record<string, Entry> => {
135  const key = keyOf(rules)
136  const was = entries[key]
137  const entry: Entry = {
138    rules: [...rules],
139    approved: (was?.approved ?? 0) + (approved ? 1 : 0),
140    denied: (was?.denied ?? 0) + (approved ? 0 : 1),
141    example,
142    lastAt: now,
143    state: was?.state ?? 'counting',
144  }
145  const next = { ...entries, [key]: entry }
146  const keys = Object.keys(next)
147  if (keys.length <= ENTRIES_KEPT) return next
148  const keep = keys.sort((a, b) => next[b]!.lastAt - next[a]!.lastAt).slice(0, ENTRIES_KEPT)
149  return Object.fromEntries(keep.map(k => [k, next[k]!]))
150}
151
152export const setState = (entries: Record<string, Entry>, key: string, state: Entry['state'], file?: string) =>
153  entries[key] ? { ...entries, [key]: { ...entries[key]!, state, ...(file ? { file } : {}) } } : entries
154
155/** An entry's counts back to zero, as if its dialog had never been answered; the rest is kept. */
156export const zero = (entries: Record<string, Entry>, key: string) =>
157  entries[key] ? { ...entries, [key]: { ...entries[key]!, approved: 0, denied: 0, state: 'counting' as const } } : entries
158
159/** Approvals toward the offer: `●●●○○ 3/5`. */
160export const progress = (approved: number) => {
161  const done = Math.max(0, Math.min(threshold, approved))
162  return `${'●'.repeat(done)}${'○'.repeat(threshold - done)} ${done}/${threshold}`
163}
164
165/** The line shown under an open dialog, or undefined when there is nothing to say yet. */
166export const noticeFor = (entry: Entry | undefined, configured: Configured, lang: Lang = 'en') => {
167  if (!entry || entry.approved === 0) return undefined
168  const w = WORDS[lang]
169  const s = status(entry, configured)
170  if (s === 'risky') return w.noticeRisky(entry.approved)
171  if (s === 'refused') return w.noticeRefused(entry.approved, entry.denied)
172  if (s === 'ready' || s === 'dismissed') return w.noticeReady(entry.approved)
173  return w.noticeCounting(progress(entry.approved), threshold - entry.approved, keyOf(entry.rules))
174}
175
176/** A settings file's permissions, read whole; throws on text that is not a JSON object. */
177const permissionsOf = (text: string, file: string) => {
178  const parsed: unknown = text.trim() === '' ? {} : JSON.parse(text)
179  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${file} is not a JSON object`)
180  const settings = parsed as Record<string, unknown>
181  const permissions = (settings.permissions ?? {}) as Record<string, unknown>
182  if (typeof permissions !== 'object' || Array.isArray(permissions)) throw new Error(`permissions in ${file} is not an object`)
183  if (permissions.allow !== undefined && !Array.isArray(permissions.allow)) throw new Error(`permissions.allow in ${file} is not a list`)
184  return { settings, permissions, allow: [...((permissions.allow as string[] | undefined) ?? [])] }
185}
186
187const write = (settings: Record<string, unknown>, permissions: Record<string, unknown>, allow: string[]) =>
188  `${JSON.stringify({ ...settings, permissions: { ...permissions, allow } }, null, 2)}\n`
189
190/**
191 * The settings file's text with `rules` added to permissions.allow, or null when every rule is
192 * there already. Throws on text that is not a JSON object, so a file it cannot read whole is
193 * never rewritten.
194 */
195export const addAllow = (text: string, rules: readonly string[], file = SETTINGS_FILE): string | null => {
196  const { settings, permissions, allow } = permissionsOf(text, file)
197  const missing = rules.filter(r => !listed(allow, r))
198  if (missing.length === 0) return null
199  return write(settings, permissions, [...allow, ...missing])
200}
201
202/**
203 * The settings file's text with `rules` taken out of permissions.allow, or null when none of
204 * them is there. Throws as addAllow does.
205 */
206export const removeAllow = (text: string, rules: readonly string[], file = SETTINGS_FILE): string | null => {
207  const { settings, permissions, allow } = permissionsOf(text, file)
208  const kept = allow.filter(a => !rules.some(r => sameRule(a, r)))
209  if (kept.length === allow.length) return null
210  return write(settings, permissions, kept)
211}
212
213export const toConfigured = (settings: { permissions?: { allow?: unknown; ask?: unknown; deny?: unknown } } | undefined): Configured => {
214  const list = (v: unknown) => (Array.isArray(v) ? v.filter((r): r is string => typeof r === 'string') : [])
215  return { allow: list(settings?.permissions?.allow), ask: list(settings?.permissions?.ask), deny: list(settings?.permissions?.deny) }
216}
217
218/** JSON with sorted keys, so two copies of one tool input compare equal. */
219export const stable = (value: unknown): string => {
220  if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`
221  if (value !== null && typeof value === 'object') {
222    const obj = value as Record<string, unknown>
223    return `{${Object.keys(obj)
224      .filter(k => obj[k] !== undefined)
225      .sort()
226      .map(k => `${JSON.stringify(k)}:${stable(obj[k])}`)
227      .join(',')}}`
228  }
229  return JSON.stringify(value) ?? 'null'
230}
231
232const ORDER: Status[] = ['ready', 'counting', 'refused', 'risky', 'dismissed', 'pinned', 'allowed']
233
234/** Entries in the order the pane lists them: ready first, then by approvals. */
235export const sorted = (entries: Record<string, Entry>, configured: Configured) =>
236  Object.entries(entries)
237    .map(([key, entry]) => ({ key, entry, status: status(entry, configured) }))
238    .sort((a, b) => ORDER.indexOf(a.status) - ORDER.indexOf(b.status) || b.entry.approved - a.entry.approved || b.entry.lastAt - a.entry.lastAt)
239
240export const TABS: readonly Tab[] = ['all', 'ready', 'counting', 'refused']
241
242/** A sorted row the pane shows under a tab and a typed filter, which matches the rule or the call that asked. */
243export const shows = (row: { key: string; entry: Entry; status: Status }, tab: Tab, query: string) => {
244  if (tab !== 'all' && row.status !== tab) return false
245  const q = query.trim().toLowerCase()
246  return q === '' || row.key.toLowerCase().includes(q) || row.entry.example.toLowerCase().includes(q)
247}
248
249/**
250 * The key an argument of /allowlist allow or dismiss names: a number as the pane numbers the
251 * rules (1 is the first), else the rule written out.
252 */
253export const keyAt = (entries: Record<string, Entry>, configured: Configured, arg: string): string => {
254  if (!/^\d+$/.test(arg)) return arg
255  return sorted(entries, configured)[Number(arg) - 1]?.key ?? arg
256}
257
258/** A text summary, for the command's answer where no pane can open; numbered as the pane is. */
259export const summary = (entries: Record<string, Entry>, configured: Configured, lang: Lang = 'en') => {
260  const w = WORDS[lang]
261  const rows = sorted(entries, configured)
262  if (rows.length === 0) return w.noDialogs
263  return rows
264    .slice(0, 15)
265    .map((r, i) => `${String(i + 1).padStart(2)} ${w.status[r.status].padEnd(10)} ✓${r.entry.approved} ✗${r.entry.denied}  ${r.key}`)
266    .concat(rows.some(r => r.status === 'ready') ? ['', w.addHint] : [])
267    .join('\n')
268}
269
hooks/ui.tsx 114 lines
1// Shared UI helpers, after launchpad's patterns. The same file in every mod that has one: a mod
2// installs alone and cannot import another's code, so scripts/check-shared.sh keeps the copies
3// equal. Change one, copy it to the others.
4//
5// Nothing here takes `$`: the engine follows `$` only into functions of the file that uses it,
6// never across an import. A call on `$` stays in register.tsx; this file gives it its arguments.
7//
8//   language and icons  the `language` and `icons` options, else the system's LANG and terminal.
9//   prompt              the arguments of $.prompt.fill: a text with its first `[blank]` marked.
10//   lists               the window of a long list a pane shows, for ui.scroll.
11//   verbs               a row of a command's arguments, one press each.
12//   numbers             tokens, elapsed time, clipped text and short model names.
13
14import type { Elements, PromptFillArgs } from 'claude-code'
15
16export type Lang = 'pt-BR' | 'en'
17export type IconStyle = 'emoji' | 'symbol'
18
19/** The language: the `language` option when it names one, else Portuguese for a pt LANG, else English. */
20export const langOf = (option: unknown, systemLang?: string | null): Lang =>
21  option === 'en' || option === 'pt-BR' ? option : /^pt([_.@-]|$)/i.test(systemLang ?? '') ? 'pt-BR' : 'en'
22
23/**
24 * The icon style: the `icons` option when it names one; on `auto` (or none), symbols in a
25 * JetBrains IDE's terminal (TERMINAL_EMULATOR=JetBrains-JediTerm), which gives many emoji one
26 * column where Claude Code counts two, and emoji everywhere else.
27 */
28export const styleOf = (option: unknown, terminal?: string | null): IconStyle =>
29  option === 'emoji' || option === 'symbol' ? option : /^JetBrains/i.test(terminal ?? '') ? 'symbol' : 'emoji'
30
31/** An icon in the style: its emoji, or the one-cell symbol that stands in for it. */
32export const glyph = (style: IconStyle, icon: { emoji: string; symbol: string }) => icon[style]
33
34const BLANK = /\[[^\]\n]+\]/
35
36/** The first `[blank]` in a text, as offsets, so the prompt can mark what to replace. */
37export const blankIn = (text: string): { start: number; end: number } | null => {
38  const m = BLANK.exec(text)
39  return m ? { start: m.index, end: m.index + m[0].length } : null
40}
41
42/** What `$.prompt.fill` takes to put a text in the prompt, its `[blank]` marked to replace. */
43export const fillArgs = (text: string): PromptFillArgs => {
44  const blank = blankIn(text)
45  return blank ? { text, decorations: [{ ...blank, bold: true, underline: true }] } : { text }
46}
47
48/** The rows of a list of `total` a pane shows from `offset`, kept inside the list. */
49export const windowOf = (total: number, offset: number, rows: number): { start: number; end: number } => {
50  const size = Math.max(1, rows)
51  const start = Math.max(0, Math.min(offset, total - size))
52  return { start, end: Math.min(total, start + size) }
53}
54
55/**
56 * One of a command's arguments in a verb row. `fill` is the text the prompt waits with, for a
57 * verb that takes an argument or undoes something: a stray click then loses nothing. A verb with
58 * no `fill` runs, and its answer goes to the transcript a line at a time (`linesOf`).
59 */
60export type Verb = { verb: string; label?: string; fill?: string }
61
62/** A command's answer as the lines `$.ui.log` writes, one row each; blank lines dropped. */
63export const linesOf = (text: string | undefined) => (text ?? '').split('\n').filter(line => line.trim() !== '')
64
65/** `/command verb · verb · verb`, each verb a plain button; `lead` goes dim before the command. */
66export function verbRow(
67  ui: Pick<Elements[keyof Elements], 'Box' | 'Text' | 'Button'>,
68  command: string,
69  verbs: readonly Verb[],
70  onPress: (v: Verb) => void,
71  lead = '',
72) {
73  const { Box, Text, Button } = ui
74  return (
75    <Box flexDirection="row" flexWrap="wrap">
76      {lead !== '' && <Text dimColor>{`${lead} · `}</Text>}
77      <Text dimColor>{`/${command} `}</Text>
78      {verbs.map((v, i) => (
79        <Box key={`verbrow:${v.verb}`} flexDirection="row">
80          {i > 0 && <Text dimColor> · </Text>}
81          <Button key={`verb:${v.verb}`} plain dimColor label={v.label ?? v.verb} onPress={() => onPress(v)} />
82        </Box>
83      ))}
84    </Box>
85  )
86}
87
88/** 950, 1.2k, 46k, 1.2M. */
89export const tokens = (n: number) => {
90  if (n < 1000) return String(n)
91  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
92  return `${(n / 1_000_000).toFixed(1)}M`
93}
94
95/** 42s, 6m 05s, 1h 02m. */
96export const elapsed = (ms: number) => {
97  const s = Math.max(0, Math.round(ms / 1000))
98  if (s < 60) return `${s}s`
99  const m = Math.floor(s / 60)
100  if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
101  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
102}
103
104/** The text cut to `n` characters, an ellipsis last when it was longer. */
105export const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
106
107/** claude-haiku-4-5-20251001 -> haiku 4.5 */
108export const shortModel = (m?: string) => {
109  if (!m) return ''
110  const hit = /(opus|sonnet|haiku|fable)[-\s]?(\d+(?:[-.]\d+)?)?/i.exec(m)
111  if (!hit) return m.length > 14 ? `${m.slice(0, 13)}…` : m
112  return `${hit[1]!.toLowerCase()}${hit[2] ? ` ${hit[2].replace('-', '.')}` : ''}`
113}
114
hooks/words.ts 217 lines
1// What allowlist-coach says, in Portuguese and English. The language comes from the `language`
2// option, else the system's LANG (ui.tsx's langOf).
3
4import type { Tab } from '../types'
5import type { Risk, Status } from './tally'
6import type { Lang } from './ui'
7
8export const COMMAND = 'allowlist'
9
10type Words = {
11  description: string
12  pane: string
13  hint: (root: string, threshold: number) => string
14  empty: string
15  status: Record<Status, string>
16  tabs: Record<Tab, string>
17  filter: string
18  placeholder: string
19  noMatch: string
20  /** The scroll row under the list: which rules show, of how many. */
21  range: (from: number, to: number, of: number) => string
22  up: string
23  down: string
24  /** Why a risky rule is never offered. */
25  risk: (r: Risk) => string
26  allow: string
27  dismiss: string
28  close: string
29  /** The line under a rule: the last call that asked, and when. */
30  example: (example: string, ago: string) => string
31  ago: (ms: number) => string
32  /** Lines under an open permission dialog. */
33  noticeRisky: (approved: number) => string
34  noticeRefused: (approved: number, denied: number) => string
35  noticeReady: (approved: number) => string
36  noticeCounting: (progress: string, left: number, rule: string) => string
37  offered: (rule: string, approved: number) => string
38  /** The question before a rule is written, with the line it adds, and its answers. */
39  askAdd: (what: string, approved: number) => string
40  preview: (rules: readonly string[]) => string
41  addLocal: string
42  addShared: string
43  notNow: string
44  never: string
45  /** The question before the counts are cleared, and its answers: Cancel first. */
46  askReset: (n: number) => string
47  cancel: string
48  clear: string
49  /** Undoing an added rule: the button, the question, its answer and the outcomes. */
50  remove: string
51  askRemove: (what: string, file: string) => string
52  removeOpt: string
53  removed: (what: string, file: string) => string
54  notInFile: (what: string, file: string) => string
55  notAdded: (key: string) => string
56  /** One rule's counts back to zero: the button, the question, its answer and the outcome. */
57  resetCount: string
58  askZero: (key: string, approved: number, denied: number) => string
59  zeroOpt: string
60  zeroed: (key: string) => string
61  noRule: (key: string) => string
62  nothingChanged: string
63  neverAgain: (what: string) => string
64  added: (what: string, file: string) => string
65  leftAsIs: (file: string, why: string) => string
66  cleared: string
67  noDialogs: string
68  addHint: string
69  help: string
70  failedToRun: (what: string) => string
71}
72
73const minutes = (ms: number) => Math.floor(ms / 60_000)
74
75export const WORDS: Record<Lang, Words> = {
76  'pt-BR': {
77    description: 'Diálogos de permissão por regra: /allowlist abre o painel; allow <n>, dismiss <n>, remove <n>, reset [<n>], help',
78    pane: 'Allowlist',
79    hint: (root, t) => `Diálogos de permissão respondidos em ${root}. Uma regra é oferecida depois de ${t} aprovações sem recusa.`,
80    empty: 'Nenhum diálogo de permissão respondido neste projeto ainda.',
81    status: { ready: 'pronta', counting: 'contando', refused: 'recusada', risky: 'arriscada', allowed: 'liberada', pinned: 'fixada', dismissed: 'dispensada' },
82    tabs: { all: 'todas', ready: 'prontas', counting: 'contando', refused: 'recusadas' },
83    filter: 'Filtrar',
84    placeholder: 'regra ou comando',
85    noMatch: 'Nenhuma regra corresponde.',
86    range: (from, to, of) => `${from}–${to} de ${of}`,
87    up: '▲ acima',
88    down: '▼ abaixo',
89    risk: r => (r.kind === 'tool' ? 'a ferramenta inteira' : r.kind === 'wildcard' ? 'curinga solto' : r.kind === 'command' ? `roda ${r.what}` : 'regra ilegível'),
90    allow: 'liberar',
91    dismiss: 'dispensar',
92    close: 'Fechar',
93    example: (ex, ago) => `ex.: ${ex} · ${ago}`,
94    ago: ms => {
95      const m = minutes(ms)
96      if (m < 1) return 'agora'
97      if (m < 60) return `há ${m} min`
98      const h = Math.floor(m / 60)
99      if (h < 24) return `há ${h} h`
100      const d = Math.floor(h / 24)
101      return d === 1 ? 'ontem' : `há ${d} dias`
102    },
103    noticeRisky: a => `allowlist-coach: aprovada ${a} ${a === 1 ? 'vez' : 'vezes'} aqui; ampla demais para virar regra`,
104    noticeRefused: (a, d) => `allowlist-coach: aprovada ${a} ${a === 1 ? 'vez' : 'vezes'}, recusada ${d} aqui`,
105    noticeReady: a => `allowlist-coach: aprovada ${a} ${a === 1 ? 'vez' : 'vezes'} aqui · /allowlist`,
106    noticeCounting: (progress, left, rule) => `allowlist-coach: ${progress} aprovações · ${left === 1 ? 'falta 1' : `faltam ${left}`} para /allowlist oferecer ${rule}`,
107    offered: (rule, a) => `Você aprovou ${rule} ${a} vezes aqui. /allowlist para adicionar ao permissions.allow.`,
108    askAdd: (what, a) => `Adicionar ${what} ao permissions.allow? Você aprovou ${a} ${a === 1 ? 'vez' : 'vezes'} aqui.`,
109    preview: rules => `Linha que entra em permissions.allow: ${rules.map(r => JSON.stringify(r)).join(', ')}`,
110    addLocal: 'Adicionar ao settings.local.json (só você)',
111    addShared: 'Adicionar ao settings.json (o time todo)',
112    notNow: 'Agora não',
113    never: 'Nunca oferecer',
114    askReset: n => `Apagar as contagens ${n === 1 ? 'da regra' : `das ${n} regras`} deste projeto? As regras já liberadas continuam em permissions.allow.`,
115    cancel: 'Cancelar',
116    clear: 'Apagar',
117    remove: 'tirar do allow',
118    askRemove: (what, file) => `Tirar ${what} do permissions.allow em ${file}? O diálogo de permissão volta a aparecer.`,
119    removeOpt: 'Tirar',
120    removed: (what, file) => `allowlist-coach: ${what} saiu do permissions.allow em ${file}; não será mais oferecida.`,
121    notInFile: (what, file) => `allowlist-coach: ${what} não está no permissions.allow de ${file}; nada mudou.`,
122    notAdded: key => `allowlist-coach: ${key} não foi adicionada pelo allowlist-coach; tire-a do arquivo de settings onde está.`,
123    resetCount: 'zerar contagem',
124    askZero: (key, a, d) => `Zerar a contagem de ${key} (${a} ${a === 1 ? 'aprovação' : 'aprovações'}, ${d} ${d === 1 ? 'recusa' : 'recusas'})?`,
125    zeroOpt: 'Zerar',
126    zeroed: key => `allowlist-coach: contagem de ${key} zerada.`,
127    noRule: key => `allowlist-coach: nenhuma regra ${key} contada neste projeto. /allowlist mostra os números.`,
128    nothingChanged: 'allowlist-coach: nada mudou.',
129    neverAgain: what => `allowlist-coach: ${what} não será mais oferecida.`,
130    added: (what, file) => `allowlist-coach: ${what} adicionada ao permissions.allow em ${file}.`,
131    leftAsIs: (file, why) => `allowlist-coach: ${file} ficou como estava: ${why}`,
132    cleared: 'allowlist-coach: contagens deste projeto apagadas.',
133    noDialogs: 'allowlist-coach: nenhum diálogo de permissão respondido neste projeto ainda.',
134    addHint: 'Adicione uma com: /allowlist allow 1',
135    help: [
136      '/allowlist             abre o painel',
137      '/allowlist allow 1     libera a regra 1 (pergunta antes)',
138      '/allowlist dismiss 1   para de oferecer a regra 1',
139      '/allowlist remove 1    tira do allow a regra 1 que o coach adicionou (pergunta antes)',
140      '/allowlist reset 1     zera a contagem da regra 1 (pergunta antes)',
141      '/allowlist reset       apaga as contagens deste projeto (pergunta antes)',
142      'O número é o do painel. A regra escrita por extenso também vale.',
143    ].join('\n'),
144    failedToRun: what => `Não deu para rodar: ${what}`,
145  },
146  en: {
147    description: 'Permission dialogs per rule: /allowlist opens the pane; allow <n>, dismiss <n>, remove <n>, reset [<n>], help',
148    pane: 'Allowlist',
149    hint: (root, t) => `Permission dialogs answered in ${root}. A rule is offered after ${t} approvals and no refusal.`,
150    empty: 'No dialogs answered in this project yet.',
151    status: { ready: 'ready', counting: 'counting', refused: 'refused', risky: 'risky', allowed: 'allowed', pinned: 'pinned', dismissed: 'dismissed' },
152    tabs: { all: 'all', ready: 'ready', counting: 'counting', refused: 'refused' },
153    filter: 'Filter',
154    placeholder: 'rule or command',
155    noMatch: 'No rule matches.',
156    range: (from, to, of) => `${from}–${to} of ${of}`,
157    up: '▲ up',
158    down: '▼ down',
159    risk: r => (r.kind === 'tool' ? 'the whole tool' : r.kind === 'wildcard' ? 'a bare wildcard' : r.kind === 'command' ? `runs ${r.what}` : 'unreadable rule'),
160    allow: 'allow',
161    dismiss: 'dismiss',
162    close: 'Close',
163    example: (ex, ago) => `e.g. ${ex} · ${ago}`,
164    ago: ms => {
165      const m = minutes(ms)
166      if (m < 1) return 'just now'
167      if (m < 60) return `${m} min ago`
168      const h = Math.floor(m / 60)
169      if (h < 24) return `${h} h ago`
170      const d = Math.floor(h / 24)
171      return d === 1 ? 'yesterday' : `${d} days ago`
172    },
173    noticeRisky: a => `allowlist-coach: approved ${a} time${a === 1 ? '' : 's'} here; too broad to offer as a rule`,
174    noticeRefused: (a, d) => `allowlist-coach: approved ${a} time${a === 1 ? '' : 's'}, refused ${d} here`,
175    noticeReady: a => `allowlist-coach: approved ${a} time${a === 1 ? '' : 's'} here · /allowlist`,
176    noticeCounting: (progress, left, rule) => `allowlist-coach: ${progress} approvals · ${left} more and /allowlist offers ${rule}`,
177    offered: (rule, a) => `You approved ${rule} ${a} times here. /allowlist to add it to permissions.allow.`,
178    askAdd: (what, a) => `Add ${what} to permissions.allow? You approved it ${a} time${a === 1 ? '' : 's'} here.`,
179    preview: rules => `The line it adds to permissions.allow: ${rules.map(r => JSON.stringify(r)).join(', ')}`,
180    addLocal: 'Add to settings.local.json (just you)',
181    addShared: 'Add to settings.json (the whole team)',
182    notNow: 'Not now',
183    never: 'Never offer it',
184    askReset: n => `Clear the counts of this project's ${n === 1 ? 'rule' : `${n} rules`}? Rules already allowed stay in permissions.allow.`,
185    cancel: 'Cancel',
186    clear: 'Clear',
187    remove: 'remove from allow',
188    askRemove: (what, file) => `Remove ${what} from permissions.allow in ${file}? Its permission dialog comes back.`,
189    removeOpt: 'Remove',
190    removed: (what, file) => `allowlist-coach: removed ${what} from permissions.allow in ${file}; it will not be offered again.`,
191    notInFile: (what, file) => `allowlist-coach: ${what} is not in permissions.allow in ${file}; nothing changed.`,
192    notAdded: key => `allowlist-coach: allowlist-coach did not add ${key}; remove it from the settings file that has it.`,
193    resetCount: 'reset count',
194    askZero: (key, a, d) => `Reset the count of ${key} (${a} approval${a === 1 ? '' : 's'}, ${d} refusal${d === 1 ? '' : 's'})?`,
195    zeroOpt: 'Reset',
196    zeroed: key => `allowlist-coach: count of ${key} reset.`,
197    noRule: key => `allowlist-coach: no rule ${key} counted in this project. /allowlist shows the numbers.`,
198    nothingChanged: 'allowlist-coach: nothing changed.',
199    neverAgain: what => `allowlist-coach: ${what} will not be offered again.`,
200    added: (what, file) => `allowlist-coach: added ${what} to permissions.allow in ${file}.`,
201    leftAsIs: (file, why) => `allowlist-coach: ${file} left as it was: ${why}`,
202    cleared: "allowlist-coach: this project's counts cleared.",
203    noDialogs: 'allowlist-coach: no permission dialogs answered in this project yet.',
204    addHint: 'Add one with: /allowlist allow 1',
205    help: [
206      '/allowlist             open the pane',
207      '/allowlist allow 1     allow rule 1 (asks first)',
208      '/allowlist dismiss 1   stop offering rule 1',
209      '/allowlist remove 1    take rule 1 out of allow, when the coach added it (asks first)',
210      "/allowlist reset 1     reset rule 1's count (asks first)",
211      "/allowlist reset       clear this project's counts (asks first)",
212      'The number is the one in the pane. The rule written out works too.',
213    ].join('\n'),
214    failedToRun: what => `Could not run: ${what}`,
215  },
216}
217
types/index.d.ts 42 lines
1/** What the person did with an entry, beyond counting it. */
2export type EntryState = 'counting' | 'offered' | 'added' | 'dismissed'
3
4/** One permission rule and how often its dialog was answered in this project. */
5export type Entry = {
6  /** The rules that would have skipped the dialog, as settings write them (`Bash(./mvnw test:*)`). */
7  rules: string[]
8  approved: number
9  denied: number
10  /** The last call that asked, short: a command, a path, a URL. */
11  example: string
12  /** When the last dialog was answered, in epoch milliseconds. */
13  lastAt: number
14  state: EntryState
15  /** The settings file the coach added the rules to, project-relative; set with `added`. */
16  file?: string
17}
18
19/** The pane's tabs: one status each, and all of them. */
20export type Tab = 'all' | 'ready' | 'counting' | 'refused'
21
22/** The permission lists as the merged settings hold them. */
23export type Configured = {
24  allow: string[]
25  ask: string[]
26  deny: string[]
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    'allowlist-coach': {
32      /** Keyed by `rules.join(', ')`. */
33      entries: Record<string, Entry>
34      configured: Configured
35      /** The pane: the first row of its list, its tab, and the text typed in its filter. */
36      offset: number
37      tab: Tab
38      filter: string
39    }
40  }
41}
42