SLOPSHOPPER

chiave

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…

newpaneguardcommandtoast
v0.1.0no licenseupdated 2026-10-25pompeitech/affreschi/plugins/chiave
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chiave
│ ┃ Chiave ✕ › fix the failing auth test and add an audit log call │ ┃ ⚿ Chiave · app · 0 approvazioni contatesogli │ ┃ ⏺ Read(src/auth.ts) │ ┃ PROPOSTE ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Ancora niente: una regola compare qui quando ⎿ Added 2 lines, removed 1 line │ ┃ la approvi 3 volte. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /chiave │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Chiave
⚿ Chiave · app · 0 approvazioni contate soglia 3 PROPOSTE Ancora niente: una regola compare qui quando la approvi 3 volte.
README

affreschi

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.

Layout

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/

Adding a mod

  1. Create the folder plugins/<mod-name>/ with its files.
  2. Add it to the plugins array in .claude-plugin/marketplace.json:
   { "name": "<mod-name>", "source": "./plugins/<mod-name>", "description": "..." }
  1. Check it: claude plugin validate ./plugins/<mod-name>

Development

Load a mod for the current session only (it reloads when you save):

claude --plugin-dir ./plugins/<mod-name>

Installing

/plugin marketplace add pompeitech/affreschi
/plugin install <mod-name>@affreschi

The mods

ModWhat it doesWhere it shows
vesuvius-themeA 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 finishesTerminal and desktop
pulcinellaThe 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 sessionsTerminal 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 sessionTerminal and desktop
spaccanapoliA 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 bandTerminal 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
lazzaroneWhen 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 themTerminal and desktop
chiaveCounts, 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 aloneTerminal and desktop

The palette

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.

Tests

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.

Source 4 files
hooks/register.tsx 270 lines
1import 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}
270
hooks/palette.ts 22 lines
1// 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
22
hooks/rules.ts 276 lines
1import 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}
276
types/index.d.ts 25 lines
1// 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