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

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.
●●●○○ 3/5, and how many are left.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).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.allow on a ready rule; dismiss and reset count on the others; remove from allow on a rule the coach added..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./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.allow, dismiss, remove and reset put the command in the prompt for you to finish and send; help answers at once.In /config:
| Option | Default | What it does |
|---|---|---|
language | auto | pt-BR or en for the pane, the line under the dialog, the questions and the toasts. auto follows LANG: Portuguese for pt_*, English otherwise |
threshold | 5 | Approvals, 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>).
Bash, Edit, Write, Read, WebFetch) or a bare wildcard (Bash(*), Read(/**)).rm, sudo, chmod, curl, ssh, git push, git reset, bash -c, node, … | sh, --force, and the like. These are counted and shown as risky.permissions.ask or permissions.deny.Dialogs that a settings hook answered, and decisions made in auto, dontAsk or bypassPermissions mode, are not counted: they are not your answer.
/plugin install allowlist-coach --marketplace ice-lfernandes/claude-code-mods
Or for one session: claude --plugin-dir ./allowlist-coach
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| allowlist-coach | No | No | Reads and writes .claude/settings.local.json, or .claude/settings.json when you pick it, after you confirm | No | No |
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.
hooks/register.tsx 444 lines1// 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}
444hooks/tally.ts 269 lines1import 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}
269hooks/ui.tsx 114 lines1// 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}
114hooks/words.ts 217 lines1// 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}
217types/index.d.ts 42 lines1/** 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