Chiave counts the commands you approve by hand and, past a threshold, offers to add the rule to the project's permissions in one click; risky commands are…

Claude Code interface mods, collected in a private pompeitech marketplace. Just as frescoes decorated the walls of Pompeii's houses, these mods decorate Claude Code's interface.
affreschi/
├── .claude-plugin/marketplace.json # the list of mods
└── plugins/
└── <mod-name>/ # one folder (one plugin) per mod
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json
│ ├── palette.ts # Vesuvius colors
│ └── register.tsx
├── types/index.d.ts # the mod's $.state contract, when it has one
└── tests/
plugins/<mod-name>/ with its files.plugins array in .claude-plugin/marketplace.json: { "name": "<mod-name>", "source": "./plugins/<mod-name>", "description": "..." }
claude plugin validate ./plugins/<mod-name>Load a mod for the current session only (it reloads when you save):
claude --plugin-dir ./plugins/<mod-name>
/plugin marketplace add pompeitech/affreschi
/plugin install <mod-name>@affreschi
| Mod | What it does | Where it shows |
|---|---|---|
vesuvius-theme | A spinner with a glowing crater and volcanic words, a stripe of color beside every tool call (magma while it runs, lava when it fails, ash once done), and the end-of-turn line as an eruption (▲ Erupted for 1m 4s) | Terminal; on the desktop, only the tool call rows |
agent-usage | /agent-usage opens a live pane (it polls every 2s while an agent runs) where every agent plays a character cast from its type or name: Pulcinella for the main conversation, the artist (beret and brush) for frontend and UI work, the nerd (glasses) for planners and architects, the explorer (head lamp), the reviewer (monocle), the guard (helmet) for security, the tester (lab mask) and the handyman (flat cap) for the rest; their eyes follow the status. In scena holds the main conversation, the agents at work and the ones that did something this turn; at the next prompt the finished ones move to the archivio, one row per role with count, tokens and failures (▸ opens it, svuota archivio clears it and keeps its tokens in the totals). Press a name for model, input/output/cache tokens, requests, tool calls and run time. Teammates and agents started before the mod loaded are listed too. The status line shows the subagents running, and a toast says what each one consumed when it finishes | Terminal and desktop |
pulcinella | The band above the prompt: Pulcinella in true pixel art: an SVG of 14×16 pixels in the desktop app, a compact 10×10 drawn two pixels to a character with half blocks (five lines) in the terminal (floppy hat, black half mask, pink cheeks, white blouse), animated two frames per mood (the hat sways and a hammer swings while a tool runs, he blinks at rest, a little z floats while he naps), beside the context window, read again after every model request: the tokens used, the headroom before compaction (vineyard green, then magma and lava past 60% and 85% of the threshold), an estimate of the turns left at the recent pace, the window split by category in one bar, and the largest categories as chips (hide/legend toggles them). details or /context-map opens a pane with every category's tokens and share, the auto-compact threshold, and what fills the window: memory files, MCP servers, custom agents, skills He frets when a command fails, cheers green tests, gasps at changes of 300+ lines, naps after ten quiet minutes, and his line in Neapolitan sits under the bar. His hat grows a pixel per level with the tokens you spend (1M, 10M, 50M, 200M), kept across sessions | Terminal and desktop |
maschere | /maschera opens a picker (or /maschera arlecchino puts one on at once) and Claude answers in that mask's voice: Pulcinella warm and ironic with a Neapolitan word now and then, Arlecchino as terse as it gets, Balanzone thorough with the reasons and trade-offs. Only the tone changes: the mask's section goes last in the system prompt and says every other instruction wins. The mask worn shows in the status line and is remembered for the next session | Terminal and desktop |
spaccanapoli | A row above the prompt with the git branch and its base, commits ahead and behind, files changed with lines added and removed (and a bar of the two), and the branch's PR from gh (open, draft, mergeable, conflicts). Changes made right on the base branch turn it into a red warning. Read again every 10s, after every tool that can change files and at the end of a turn; outside a repository it stays away. Next to pulcinella each keeps the other's band | Terminal and desktop |
golfo | /golfo opens Il Golfo: the last seven days as bars of cost and tokens, today and the priciest day picked out, and the week's cost by project, recorded across sessions (90 days kept). With a weeklyBudget set in the plugin's options, a bar shows how much of it is spent, and a toast warns at 80% and 100% | Terminal and desktop |
lazzarone | When Claude has been waiting on you for minutes (3 by default, in the plugin's options): a permission, a question, a plan to approve, or a turn of two minutes or more just finished, a system notification says what it waits for. One call per wait; answering or writing a prompt clears them | Terminal and desktop |
chiave | Counts, per project, the calls you approve by hand, as the rule that would allow them (Bash(npm run lint:*), WebFetch(domain:docs.github.com), an MCP tool), with a line under the permission dialog that keeps the tally. Past threshold approvals (3 by default) a toast says a rule is ready, and /chiave offers it: Consenti nel progetto writes it into .claude/settings.json, Solo per me into .claude/settings.local.json, Ignora never offers it again, togli takes an added rule back. Risky commands (rm, sudo, git push, curl, mv…) and chained or piped ones are never offered; a settings file that is not plain JSON is left alone | Terminal and desktop |
The colors live in each mod's hooks/palette.ts (a plugin cannot import another plugin's files, so each keeps a copy). They are placeholders, to be replaced with the Vesuvius design system's.
claude plugin validate ./plugins/<mod-name>
claude plugin test ./plugins/<mod-name>
npx -y @biomejs/biome@2.2.4 check plugins
Code follows the officina standards: Biome in StandardJS style (biome.json), braced blocks set off by blank lines, no as casts beyond as const (values the engine hands over as unknown are narrowed by checks), and failures logged to the debug log with $.ui.log(..., { to: 'debug' }), never swallowed.
hooks/register.tsx 270 lines1import type { EngineInterface, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { Added, Ledger, Scope } from '../types'
5import { VESUVIUS } from './palette'
6import {
7 countApproval,
8 EMPTY_LEDGER,
9 isRefused,
10 ledgerFrom,
11 offersOf,
12 settingsPath,
13 type Verdict,
14 verdictOf,
15 withRule
16} from './rules'
17
18const PANE = 'chiave'
19const KEY = '#F2C14E'
20const BADGE_TEXT = '#1A1A1A'
21
22const ledger = atom({ plugin: 'chiave', key: 'ledger' } as const, EMPTY_LEDGER)
23
24// The module's own: the project, and the calls put to you that are still waiting for an answer.
25let cwd = ''
26const pending = new Map<string, Verdict>()
27
28const storeKey = (): string => `ledger:${cwd}`
29
30function logFailure($: EngineInterface, what: string, error: unknown): void {
31 const reason = error instanceof Error ? error.message : String(error)
32 $.ui.log(`chiave: ${what} failed: ${reason}`, { to: 'debug' })
33}
34
35async function save($: EngineInterface, change: (l: Ledger) => Ledger): Promise<Ledger> {
36 const next = change(await read($, ledger))
37 await update($, ledger, () => next)
38 await $.store.set(storeKey(), next)
39
40 return next
41}
42
43// Writes `rule` into (or, with `remove`, out of) a settings file. Refuses to touch a file that is
44// not plain JSON, and says so.
45async function writeRule(
46 $: EngineInterface,
47 rule: string,
48 scope: Scope,
49 remove = false
50): Promise<boolean> {
51 const path = settingsPath(scope)
52 // From the session's project, not from wherever the process runs.
53 const file = `${cwd}/${path}`
54 const text = (await $.fs.exists(file)) ? await $.fs.read(file) : null
55 const next = withRule(typeof text === 'string' ? text : null, rule, remove)
56
57 if (next === null) {
58 $.ui.toast(`Chiave: ${path} non è JSON valido, non l'ho toccato`)
59
60 return false
61 }
62
63 await $.fs.write(file, next)
64
65 return true
66}
67
68async function allow($: EngineInterface, rule: string, scope: Scope): Promise<void> {
69 if (await writeRule($, rule, scope)) {
70 const at = await $.clock.now()
71 await save($, l => ({ ...l, added: [...l.added, { rule, scope, at }] }))
72 $.ui.toast(`⚿ ${rule} consentito in ${settingsPath(scope)}`)
73 }
74}
75
76async function ignore($: EngineInterface, rule: string): Promise<void> {
77 await save($, l => ({ ...l, ignored: [...l.ignored, rule] }))
78}
79
80async function takeBack($: EngineInterface, added: Added): Promise<void> {
81 if (await writeRule($, added.rule, added.scope, true)) {
82 await save($, l => ({ ...l, added: l.added.filter(a => a.rule !== added.rule) }))
83 $.ui.toast(`⚿ ${added.rule} tolto da ${settingsPath(added.scope)}`)
84 }
85}
86
87export const register: Register = (on, options) => {
88 const threshold =
89 typeof options.threshold === 'number' && options.threshold >= 1
90 ? Math.floor(options.threshold)
91 : 3
92
93 on('session.start', async ($, e, next) => {
94 cwd = e.cwd
95 pending.clear()
96 await $.command.register({
97 name: 'chiave',
98 description: 'Open Chiave: the rules you keep approving, ready to allow for good',
99 immediate: true
100 })
101 const stored = ledgerFrom(await $.store.get(storeKey()))
102 await update($, ledger, () => stored)
103
104 return next(e)
105 })
106
107 on('command.run', { command: 'chiave' }, async $ => {
108 await $.ui.open({ id: PANE, title: 'Chiave', focus: true, closeOnEscape: true })
109
110 return {}
111 })
112
113 // A call put to you: remembered until it is answered, with a line under the dialog that keeps
114 // the count, or says why it will never be offered.
115 on('tool.check', async ($, e, next) => {
116 const verdict = await next(e)
117
118 if (verdict.decision !== 'ask' || e.tool_use_id === undefined) {
119 return verdict
120 }
121
122 const seen = verdictOf(e.tool, e.input)
123 pending.set(e.tool_use_id, seen)
124 const count =
125 seen.kind === 'rule' ? ((await read($, ledger)).tallies[seen.rule]?.count ?? 0) + 1 : 0
126 const line =
127 seen.kind === 'risky'
128 ? `⚿ Chiave · non lo proporrò come regola: ${seen.reason}`
129 : seen.kind === 'rule' && count >= 2
130 ? `⚿ Chiave · ${count}ª volta su ${threshold} che approvi ${seen.rule}`
131 : undefined
132
133 if (line !== undefined) {
134 try {
135 $.ui.notice(e.tool_use_id, line)
136 } catch (error) {
137 logFailure($, 'showing the count under the dialog', error)
138 }
139 }
140
141 return verdict
142 })
143
144 // The call went ahead: you approved it, so it counts; a refusal does not.
145 on('tool.call', async ($, e, next) => {
146 try {
147 const result = await next(e)
148 const seen = pending.get(e.tool_use_id)
149
150 if (seen !== undefined && !isRefused(result)) {
151 const at = await $.clock.now()
152 const updated = await save($, l => countApproval(l, seen, at))
153
154 if (seen.kind === 'rule' && updated.tallies[seen.rule]?.count === threshold) {
155 $.ui.toast(`⚿ Chiave · nuova regola pronta: ${seen.rule} · /chiave`)
156 }
157 }
158
159 return result
160 } finally {
161 pending.delete(e.tool_use_id)
162 }
163 })
164
165 // The pane: the rules to offer, the risky calls never offered, and the rules already added.
166 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
167 const { Box, Text, Button } = $.ui.resolve(e)
168 const l = await read($, ledger)
169 const offers = offersOf(l, threshold)
170 const risky = Object.entries(l.risky).toSorted((a, b) => b[1].count - a[1].count)
171 const total = Object.values(l.tallies).reduce((sum, t) => sum + t.count, 0)
172 const project = cwd.split('/').filter(Boolean).at(-1) ?? cwd
173
174 return (
175 <Box flexDirection="column" rowGap={1}>
176 <Box flexDirection="row" justifyContent="space-between">
177 <Text>
178 <Text color={KEY}>⚿</Text> <Text bold>Chiave</Text>
179 <Text color={VESUVIUS.ash}>
180 {' '}
181 · {project} · {total} {total === 1 ? 'approvazione contata' : 'approvazioni contate'}
182 </Text>
183 </Text>
184 <Text color={VESUVIUS.ash}>soglia {threshold}</Text>
185 </Box>
186 <Text color={VESUVIUS.ash}>PROPOSTE</Text>
187 {offers.length === 0 && (
188 <Text color={VESUVIUS.ash}>
189 Ancora niente: una regola compare qui quando la approvi {threshold} volte.
190 </Text>
191 )}
192 {offers.map(([rule, tally]) => (
193 <Box flexDirection="column" borderStyle="round" borderColor={VESUVIUS.ash} paddingX={1}>
194 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
195 <Text mono bold wrap="truncate-end">
196 {rule}
197 </Text>
198 <Text color={KEY}>
199 {'●'.repeat(Math.min(tally.count, 8))}{' '}
200 <Text color={VESUVIUS.ash}>{tally.count} volte</Text>
201 </Text>
202 </Box>
203 {tally.examples.length > 0 && (
204 <Text mono color={VESUVIUS.ash} wrap="truncate-end">
205 es. {tally.examples.join(' · ')}
206 </Text>
207 )}
208 <Box flexDirection="row" columnGap={1}>
209 <Button
210 key={`project-${rule}`}
211 label="Consenti nel progetto"
212 onPress={() => allow($, rule, 'project')}
213 />
214 <Button
215 key={`local-${rule}`}
216 label="Solo per me"
217 onPress={() => allow($, rule, 'local')}
218 />
219 <Button key={`ignore-${rule}`} plain label="Ignora" onPress={() => ignore($, rule)} />
220 </Box>
221 </Box>
222 ))}
223 {risky.length > 0 && (
224 <Box flexDirection="column">
225 <Text color={VESUVIUS.ash}>
226 NON LE PROPONGO · troppo rischiose da consentire sempre
227 </Text>
228 {risky.map(([label, r]) => (
229 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
230 <Text mono color={VESUVIUS.lava} wrap="truncate-end">
231 {label}
232 </Text>
233 <Text color={VESUVIUS.ash}>
234 {r.reason} · approvato {r.count} {r.count === 1 ? 'volta' : 'volte'}
235 </Text>
236 </Box>
237 ))}
238 </Box>
239 )}
240 {l.added.length > 0 && (
241 <Box flexDirection="column">
242 <Text color={VESUVIUS.ash}>GIÀ AGGIUNTE DA CHIAVE</Text>
243 {l.added.map(a => (
244 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
245 <Text mono wrap="truncate-end">
246 {a.rule}
247 </Text>
248 <Box flexDirection="row" columnGap={1}>
249 <Text
250 backgroundColor={a.scope === 'project' ? KEY : VESUVIUS.ash}
251 color={BADGE_TEXT}
252 >
253 {a.scope === 'project' ? ' progetto ' : ' solo io '}
254 </Text>
255 <Button
256 key={`remove-${a.rule}`}
257 plain
258 label="togli"
259 onPress={() => takeBack($, a)}
260 />
261 </Box>
262 </Box>
263 ))}
264 </Box>
265 )}
266 </Box>
267 )
268 })
269}
270hooks/palette.ts 22 lines1// Vesuvius palette (placeholder): replace the hex values with the design system's.
2// Every mod in affreschi keeps its own copy, because a plugin cannot import another plugin's files.
3export const VESUVIUS = {
4 lava: '#E4572E',
5 magma: '#F28C28',
6 sulfur: '#F2C14E',
7 tuff: '#C9B79C',
8 ash: '#8A8D91',
9 gulf: '#2E86AB',
10 vineyard: '#7A9E4E'
11} as const
12
13// The order categories take their colors in, warmest first.
14export const SEQUENCE = [
15 VESUVIUS.lava,
16 VESUVIUS.magma,
17 VESUVIUS.sulfur,
18 VESUVIUS.tuff,
19 VESUVIUS.gulf,
20 VESUVIUS.vineyard
21] as const
22hooks/rules.ts 276 lines1import type { Ledger, Scope, Tally } from '../types'
2
3// From a call to the rule that would allow it, which calls are too risky to allow for good,
4// the ledger of approvals, and the settings file a rule is written into.
5
6// What a call comes to: a rule Chiave may offer, a risky call it never will, or nothing to count.
7export type Verdict =
8 | { kind: 'rule'; rule: string; example: string }
9 | { kind: 'risky'; label: string; reason: string }
10 | { kind: 'skip' }
11
12export const EMPTY_LEDGER: Ledger = { tallies: {}, risky: {}, ignored: [], added: [] }
13
14// How many examples each rule keeps, and how many words a Bash rule takes at most.
15const EXAMPLES = 3
16const MAX_WORDS = 4
17
18// Commands that change or destroy things outside the work at hand: never allowed for good.
19const RISKY: readonly (readonly [RegExp, string])[] = [
20 [/^(sudo|su|doas)\b/, 'dà i permessi di amministratore'],
21 [/^(rm|rmdir|shred|dd|mkfs)\b/, 'cancella o sovrascrive file'],
22 [/^(chmod|chown)\b/, 'cambia permessi o proprietari'],
23 [/^(kill|killall|pkill|shutdown|reboot)\b/, 'ferma processi o la macchina'],
24 [/^(curl|wget)\b/, 'scarica dalla rete'],
25 [/^git\s+(push|reset|clean|rebase|filter-branch)\b/, 'riscrive o pubblica la storia git'],
26 [/^git\s+(branch|tag)\s+-[dD]\b/, 'cancella branch o tag'],
27 [/^git\s+checkout\s+--\s/, 'butta via modifiche'],
28 [/^(npm|yarn|pnpm|bun)\s+publish\b/, 'pubblica un pacchetto'],
29 [/^(docker|podman)\s+(rm|rmi|system\s+prune)\b/, 'cancella container o immagini'],
30 [/^kubectl\s+(delete|apply)\b/, 'modifica un cluster'],
31 [/^(mv)\b/, 'può sovrascrivere file']
32]
33
34// A word that names a file or a path: where a Bash rule stops.
35function isPath(word: string): boolean {
36 return (
37 word === '.' ||
38 word === '..' ||
39 word.startsWith('./') ||
40 word.startsWith('../') ||
41 word.startsWith('/') ||
42 word.startsWith('~') ||
43 (word.includes('/') && !word.startsWith('@')) ||
44 /^["'`]/.test(word)
45 )
46}
47
48// The leading words of a command that make its rule: the program, flags that come before the
49// first subcommand, and subcommands, stopping at a path, a later flag or four words.
50export function prefixOf(command: string): string {
51 const words = command.trim().split(/\s+/)
52 const kept: string[] = []
53 let hasSubcommand = false
54
55 for (const word of words) {
56 if (kept.length >= MAX_WORDS || isPath(word)) {
57 break
58 }
59
60 if (word.startsWith('-')) {
61 if (hasSubcommand) {
62 break
63 }
64
65 kept.push(word)
66 continue
67 }
68
69 if (kept.length > 0) {
70 hasSubcommand = true
71 }
72
73 kept.push(word)
74 }
75
76 return kept.join(' ')
77}
78
79function stringField(input: unknown, field: string): string | undefined {
80 if (typeof input !== 'object' || input === null || !(field in input)) {
81 return undefined
82 }
83
84 const value: unknown = Reflect.get(input, field)
85
86 return typeof value === 'string' ? value : undefined
87}
88
89// What a call put to you comes to.
90export function verdictOf(tool: string, input: unknown): Verdict {
91 if (tool === 'Bash') {
92 const command = (stringField(input, 'command') ?? '').trim()
93
94 if (command === '') {
95 return { kind: 'skip' }
96 }
97
98 const risky = RISKY.find(([pattern]) => pattern.test(command))
99
100 if (risky !== undefined) {
101 return { kind: 'risky', label: command.split('\n')[0] ?? command, reason: risky[1] }
102 }
103
104 // Chained, piped, redirected or substituted commands are more than their first word.
105 if (/[;&|<>`]|\$\(/.test(command)) {
106 return { kind: 'skip' }
107 }
108
109 return { kind: 'rule', rule: `Bash(${prefixOf(command)}:*)`, example: command }
110 }
111
112 if (tool === 'WebFetch') {
113 const url = stringField(input, 'url')
114 const host = url === undefined ? undefined : /^https?:\/\/([^/:?#]+)/i.exec(url)?.[1]
115
116 return host === undefined
117 ? { kind: 'skip' }
118 : { kind: 'rule', rule: `WebFetch(domain:${host})`, example: url ?? host }
119 }
120
121 // Edits are the mode's to settle, and a question or a plan always needs you.
122 if (['Edit', 'Write', 'NotebookEdit', 'AskUserQuestion', 'ExitPlanMode'].includes(tool)) {
123 return { kind: 'skip' }
124 }
125
126 return { kind: 'rule', rule: tool, example: tool }
127}
128
129// Counts one approval: a rule's tally grows (keeping its latest examples), a risky call is noted.
130export function countApproval(ledger: Ledger, verdict: Verdict, at: number): Ledger {
131 if (verdict.kind === 'rule') {
132 const before: Tally = ledger.tallies[verdict.rule] ?? { count: 0, examples: [], lastAt: 0 }
133 const examples = [verdict.example, ...before.examples.filter(x => x !== verdict.example)].slice(
134 0,
135 EXAMPLES
136 )
137
138 return {
139 ...ledger,
140 tallies: {
141 ...ledger.tallies,
142 [verdict.rule]: { count: before.count + 1, examples, lastAt: at }
143 }
144 }
145 }
146
147 if (verdict.kind === 'risky') {
148 const before = ledger.risky[verdict.label]?.count ?? 0
149
150 return {
151 ...ledger,
152 risky: { ...ledger.risky, [verdict.label]: { count: before + 1, reason: verdict.reason } }
153 }
154 }
155
156 return ledger
157}
158
159// What a refused call's result says (by you, or with nobody there to answer), so a refusal is not
160// counted as an approval.
161const REFUSED = /doesn't want to proceed|was rejected|has been denied|denied by the user/i
162
163// Whether a call put to you was refused rather than approved: a hook's deny, or an error that
164// says so. A command that ran and failed was still approved.
165export function isRefused(result: { deny?: string; isError?: boolean; text?: string }): boolean {
166 return result.deny !== undefined || (result.isError === true && REFUSED.test(result.text ?? ''))
167}
168
169// The rules ready to offer: approved at least `threshold` times, neither ignored nor added,
170// the most approved first.
171export function offersOf(ledger: Ledger, threshold: number): [string, Tally][] {
172 const settled = new Set([...ledger.ignored, ...ledger.added.map(a => a.rule)])
173
174 return Object.entries(ledger.tallies)
175 .filter(([rule, tally]) => tally.count >= threshold && !settled.has(rule))
176 .toSorted((a, b) => b[1].count - a[1].count)
177}
178
179// The settings file a scope writes to, relative to the project.
180export function settingsPath(scope: Scope): string {
181 return scope === 'project' ? '.claude/settings.json' : '.claude/settings.local.json'
182}
183
184// A settings file's text with `rule` added to (or, with `remove`, taken from) `permissions.allow`,
185// every other setting kept; null when the text is not a JSON object, so nothing is overwritten.
186export function withRule(text: string | null, rule: string, remove = false): string | null {
187 let data: unknown = {}
188
189 if (text !== null && text.trim() !== '') {
190 try {
191 data = JSON.parse(text)
192 } catch {
193 return null
194 }
195 }
196
197 if (typeof data !== 'object' || data === null || Array.isArray(data)) {
198 return null
199 }
200
201 const permissions: unknown = Reflect.get(data, 'permissions') ?? {}
202
203 if (typeof permissions !== 'object' || permissions === null || Array.isArray(permissions)) {
204 return null
205 }
206
207 const allow: unknown = Reflect.get(permissions, 'allow') ?? []
208 const rules = Array.isArray(allow) ? allow.filter(r => typeof r === 'string') : []
209 const next = remove ? rules.filter(r => r !== rule) : [...new Set([...rules, rule])]
210
211 return `${JSON.stringify({ ...data, permissions: { ...permissions, allow: next } }, null, 2)}\n`
212}
213
214function entriesOf(value: unknown): [string, unknown][] {
215 return typeof value === 'object' && value !== null ? Object.entries(value) : []
216}
217
218// The ledger a value read back from the store holds, keeping only what is shaped right.
219export function ledgerFrom(value: unknown): Ledger {
220 if (typeof value !== 'object' || value === null) {
221 return EMPTY_LEDGER
222 }
223
224 const read = (field: string): unknown => Reflect.get(value, field)
225 const tallies: Ledger['tallies'] = {}
226 const risky: Ledger['risky'] = {}
227
228 for (const [rule, t] of entriesOf(read('tallies'))) {
229 const count: unknown = typeof t === 'object' && t !== null ? Reflect.get(t, 'count') : undefined
230 const examples: unknown =
231 typeof t === 'object' && t !== null ? Reflect.get(t, 'examples') : undefined
232 const lastAt: unknown =
233 typeof t === 'object' && t !== null ? Reflect.get(t, 'lastAt') : undefined
234
235 if (typeof count === 'number') {
236 tallies[rule] = {
237 count,
238 examples: Array.isArray(examples) ? examples.filter(x => typeof x === 'string') : [],
239 lastAt: typeof lastAt === 'number' ? lastAt : 0
240 }
241 }
242 }
243
244 for (const [label, r] of entriesOf(read('risky'))) {
245 const count: unknown = typeof r === 'object' && r !== null ? Reflect.get(r, 'count') : undefined
246 const reason: unknown =
247 typeof r === 'object' && r !== null ? Reflect.get(r, 'reason') : undefined
248
249 if (typeof count === 'number' && typeof reason === 'string') {
250 risky[label] = { count, reason }
251 }
252 }
253
254 const ignored = read('ignored')
255 const added = read('added')
256
257 return {
258 tallies,
259 risky,
260 ignored: Array.isArray(ignored) ? ignored.filter(x => typeof x === 'string') : [],
261 added: Array.isArray(added)
262 ? added.flatMap(a => {
263 const rule: unknown =
264 typeof a === 'object' && a !== null ? Reflect.get(a, 'rule') : undefined
265 const scope: unknown =
266 typeof a === 'object' && a !== null ? Reflect.get(a, 'scope') : undefined
267 const at: unknown = typeof a === 'object' && a !== null ? Reflect.get(a, 'at') : undefined
268
269 return typeof rule === 'string' && (scope === 'project' || scope === 'local')
270 ? [{ rule, scope, at: typeof at === 'number' ? at : 0 }]
271 : []
272 })
273 : []
274 }
275}
276types/index.d.ts 25 lines1// A rule you keep approving, in the permissions' own spelling (`Bash(npm run lint:*)`).
2export type Tally = { count: number; examples: string[]; lastAt: number }
3
4// A call Chiave will never offer to allow, and why.
5export type Risky = { count: number; reason: string }
6
7// Where an added rule went: the project's shared settings, or yours alone.
8export type Scope = 'project' | 'local'
9
10export type Added = { rule: string; scope: Scope; at: number }
11
12// Everything Chiave knows about one project.
13export type Ledger = {
14 tallies: Record<string, Tally>
15 risky: Record<string, Risky>
16 ignored: string[]
17 added: Added[]
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 chiave: { ledger: Ledger }
23 }
24}
25