SLOPSHOPPER

keyfob

API tokens for Claude Code, your shell and your scripts: kept in the macOS Keychain, the Linux Secret Service or a 0600 file on servers, handed to one command…

newpaneguardcommandtoastprompt
v0.2.0CC-BY-NC-SA-4.0updated 2026-10-07yushiran/keyfob/plugins/keyfob
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keyfob
│ ┃ keyfob ✕ › fix the failing auth test and add an audit log call │ ┃ 🔑 keyfob │ ┃ ✗ exit 0 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ + new ] [ refresh ] [ live check ] [ close ⏺ Update(src/auth.ts) │ ┃ Values never leave this panel · keyfob run … ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /keyfob │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · keyfob
🔑 keyfob ✗ exit 0 [ + new ] [ refresh ] [ live check ] [ close ] Values never leave this panel · keyfob run <name> -- <comma…
README

keyfob

API tokens for Claude Code, your shell and your scripts, kept out of everyone's way.

  • Stored by the operating system: the macOS Keychain, the Linux Secret Service, or, on a server or cluster with neither, one 0600 file in a 0700 folder (what Claude Code itself does for its own login on Linux).
  • Handed to one command at a time: keyfob run openai -- python3 eval.py. Nothing is exported into your shell, so nothing else (and no model) can read it from the environment.
  • Asked for by name: when Claude needs a key it calls keyfob_request; /keyfob opens on that secret with a masked field (bullets on screen), you paste and press store, and a prompt tells Claude to go on. The value never becomes a message.
  • Pasted straight into the chat: send keyfob: s2-key <token> to Claude Code and the plugin stores it before the message is kept; the model and the transcript get keyfob: s2-key [stored in keyfob]. A bare token it recognises (OpenAI, Anthropic, GitHub, Hugging Face, AWS, Slack, Google, Stripe, JWT) is stored as pasted-1.
  • A guard for Claude's Bash: commands that would print a secret (keyfob get, env, echo $TOKEN, reading the store), carry a literal token, or need a group's secrets without keyfob run are refused with the fix. It splits the command like a shell does, so a word inside a quoted grep pattern is never taken for a command.
  • Declared by plugins: a plugin lists what it needs in a keyfob.json; keyfob finds it, /keyfob shows what is missing and where to get it, keyfob check --live asks each service whether its secrets still work.

One static binary (Rust) for macOS (arm64, x86_64) and Linux (x86_64, aarch64, musl).

Install

As a Claude Code plugin (the binary comes with it, fetched once and checked against the release's SHA-256):

/plugin marketplace add yushiran/keyfob
/plugin install keyfob@keyfob

On its own, for a server or a script: download keyfob-<target>.tar.gz from Releases and check it against its .sha256, or cargo install --git https://github.com/yushiran/keyfob.

Use

keyfob add openai-key --env OPENAI_API_KEY      # asks without echo; or paste `keyfob: openai-key <token>` in Claude Code
keyfob run openai-key -- python3 eval.py        # the script reads os.environ["OPENAI_API_KEY"]
keyfob run binance -- binance-cli spot ping     # a group a plugin declared: several variables at once
keyfob ls                                       # names, variables, who needs them; never values
keyfob check --live                             # complete? still accepted by the service?
keyfob doctor                                   # storage in use; plaintext secrets left in rc files or Claude settings
CommandDoes
`add <name> [--env VAR] [--note T] [--from-env VAR \--stdin]`store; asks on the terminal by default
request <name> [--env VAR] --reason T [--obtain URL]record that a secret is wanted (no value) in ~/.config/keyfob/declarations.d/requested.json; /keyfob lists it to add
`run <group\name\name=VAR>... -- <cmd>`run cmd with those secrets in its environment (env is an alias)
ls, check [--live], which VAR, decls, doctorlook, never show values; --json on each
get <name>print a value: your terminal, your programs (the guard refuses it in Claude's Bash)
rm <name>, rename <old> <new>the guard asks you before an rm Claude runs
migrate --keychain-prefix <old/>copy Keychain items another tool wrote as <old/><name>
capture, scrub, patterns, guardused by the Claude Code plugin

Storage: KEYFOB_BACKEND or "backend" in ~/.config/keyfob/config.json (keychain, secret-service, file, auto). On macOS keyfob goes through Apple's /usr/bin/security, so the Keychain's access list stays the same across keyfob upgrades (no "allow access" dialog after each) and values travel on pipes, never in an argv.

For plugin authors: keyfob.json

Put it at your plugin's root. keyfob reads it from every enabled plugin; people add their own in ~/.config/keyfob/declarations.d/. Schema: plugins/keyfob/schema/keyfob.schema.json.

{
  "name": "scholar-tools",
  "secrets": {
    "s2-key": { "env": "S2_API_KEY", "description": "Semantic Scholar API key",
                "obtain": "https://www.semanticscholar.org/product/api#api-key-form" }
  },
  "groups": {
    "scholar": {
      "env": { "S2_API_KEY": "s2-key", "OPENALEX_API_KEY": "openalex-key" },
      "require_for": ["(^|\\s)python3?\\s+\\S*scholar_search\\.py"],
      "check": { "type": "http", "url": "https://api.semanticscholar.org/graph/v1/paper/CorpusId:1",
                 "headers": { "x-api-key": "${S2_API_KEY}" } }
    }
  },
  "guard": { "deny_paths": ["~/.config/scholar/keys.env"] }
}

A group may list "optional": ["S2_API_KEY"]: those variables are passed when stored and skipped when not, so keyfob run scholar -- runs whether or not the person has that key.

Your scripts read the variables; your docs say keyfob run scholar -- <command>. An MCP server that needs a key is started as keyfob run <name> -- <server command> in its MCP config.

What it protects, and what it does not

  • It keeps tokens out of rc files, settings files, the environment of every process, the conversation with the model, and (for pasted tokens and Bash commands) the transcript Claude Code writes to disk.
  • The guard is a seatbelt against mistakes, not a sandbox: a command written to get around it can. It fails open if it cannot read its input.
  • Whatever runs as you can read what you can read: on a server the file store protects against other users, not against root; on macOS the Keychain asks before another app reads an item.
  • A pasted token is stored and replaced before the message enters the session; Claude Code's own prompt history file (~/.claude/history.jsonl) is written by the terminal, and is not covered unless that turns out otherwise on your version: check with a dummy token, and use /keyfob (its input never becomes a message) when it matters.

Develop

cargo test (add KEYFOB_TEST_KEYCHAIN=1 on macOS for the Keychain round trip), claude plugin validate plugins/keyfob, claude plugin test plugins/keyfob. In a checkout the launcher uses target/release or target/debug. Releases: bump version in Cargo.toml, plugin.json and VERSION= in plugins/keyfob/bin/keyfob, then push a v<version> tag.

Licensed under CC BY-NC-SA 4.0: free to use, share and adapt with attribution, for non-commercial purposes, under the same license. Version 0.1.0 was published under MIT.

Source 1 files
hooks/register.js 336 lines
1// keyfob mod.
2//   paste capture   a message holding `keyfob: <name> <token> [ENV_VAR]` lines or a recognisable token is
3//                   stored by `keyfob capture` before it enters the conversation; the model and the transcript
4//                   get the message with each value replaced, plus a note of what was stored. If storing
5//                   fails the message is not sent at all, so a token never slips through.
6//   /keyfob         a panel: what is needed (Claude's requests first, then what plugins declare) and what is
7//                   stored; a masked field stores a value without it ever becoming a message.
8//   keyfob_status   a tool for the model: names, groups, completeness; never a value.
9//   keyfob_request  a tool for the model: asks for a secret by name; the panel opens on it, and once the
10//                   person stores it (or puts it off) a prompt from keyfob tells the model to go on.
11//                   A hook has 10 s of its own time, so the tool returns at once instead of waiting.
12
13const PANE = 'keyfob'
14const LINE = /^[ \t]*keyfob:[ \t]*([a-z0-9][a-z0-9._-]{0,63})[ \t]+(\S+)/m
15const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/
16const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
17const BULLET = '•'
18// theme keys, so the panel follows the person's light or dark theme
19const C = { accent: 'claude', needed: 'warning', ok: 'success', bad: 'error', dim: 'subtle', frame: 'promptBorder' }
20let tokens = []
21let report = null
22let busy = ''
23let lastError = ''
24let adding = null          // { name, env, why, isNew } the masked field is open for
25let secret = ''            // what the masked field really holds; wiped after every store, later and close
26let naming = false         // the [+ new] name field is showing
27const asked = new Map()    // name -> { env } asked for by Claude this session; told back once settled
28
29async function bin($) {
30  const root = typeof $.plugin.root === 'function' ? await $.plugin.root() : $.plugin.root
31  return root + '/bin/keyfob'
32}
33
34async function kf($, args, opts) {
35  return $.process.run([await bin($)].concat(args), Object.assign({ timeoutMs: 60000 }, opts || {}))
36}
37
38async function json($, args, timeoutMs) {
39  const r = await kf($, args, { timeoutMs: timeoutMs || 60000 })
40  if (!r.stdout.trim()) throw new Error((r.stderr || '').trim().slice(0, 200) || 'exit ' + r.exitCode)
41  return JSON.parse(r.stdout)
42}
43
44async function loadPatterns($) {
45  try {
46    const p = await json($, ['patterns', '--json'])
47    tokens = (p.tokens || []).map((t) => { try { return new RegExp(t.regex) } catch (err) { return null } }).filter(Boolean)
48  } catch (err) { tokens = [] }
49}
50
51async function refresh($, live) {
52  if (busy) return
53  busy = live ? 'checking with each service…' : 'reading…'
54  $.ui.invalidate('ui.render')
55  try {
56    const [check, ls] = await Promise.all([json($, ['check', '--json'].concat(live ? ['--live'] : []), live ? 120000 : 60000), json($, ['ls', '--json'])])
57    report = { check, ls, live: !!live, at: Date.now() }
58    lastError = ''
59  } catch (err) {
60    lastError = String(err && err.message ? err.message : err)
61  }
62  busy = ''
63  $.ui.invalidate('ui.render')
64}
65
66function captureNotes(c) {
67  const notes = []
68  for (const s of c.stored || []) notes.push('keyfob stored the secret ' + s.name + ' (' + s.length + ' characters); a command gets it as $' + s.env + ' when run as `keyfob run ' + s.name + ' -- <command>`. The value is not in this conversation and you cannot read it.')
69  for (const p of c.pending || []) notes.push('A token in this message was stored by keyfob as ' + p.name + ' and replaced by [keyfob:' + p.name + ']. Ask the user what it is for, then name it: `keyfob rename ' + p.name + ' <name>`.')
70  for (const f of c.failed || []) notes.push('keyfob could not store ' + f.name + ' (' + f.error + '); its value was removed from the message. Ask the user to paste it again or run `keyfob add ' + f.name + '` in their terminal.')
71  return notes.join('\n')
72}
73
74/**
75 * The masked field shows one bullet per character it holds; from what the field now shows, work out the real
76 * text: bullets kept at the start and the end stand for the characters they replaced, anything else was typed.
77 */
78export function nextSecret(prev, shown) {
79  const s = String(shown == null ? '' : shown)
80  if (!s.includes(BULLET)) return s                                 // empty, typed fresh, or pasted over
81  let p = 0
82  while (p < s.length && s[p] === BULLET) p++
83  let q = 0
84  while (q < s.length - p && s[s.length - 1 - q] === BULLET) q++
85  const typed = s.slice(p, s.length - q).split(BULLET).join('')
86  if (!typed) return prev.slice(0, Math.min(prev.length, s.length)) // only deleted: from the end
87  if (p + q > prev.length) return prev + typed
88  return prev.slice(0, p) + typed + prev.slice(prev.length - q)
89}
90
91function tell($, text) {
92  // a turn of its own once the session is idle; the model reads it as from keyfob
93  try { void $.prompt.submit({ text }) } catch (err) { /* the person still sees the toast */ }
94}
95
96function close($) {
97  secret = ''
98  adding = null
99  naming = false
100  $.ui.invalidate('ui.render')
101}
102
103async function storeValue($, name, env) {
104  const value = secret
105  secret = ''
106  adding = null
107  if (!value.trim()) { $.ui.invalidate('ui.render'); return }
108  const r = await kf($, ['add', name, '--stdin', '--note', 'added in /keyfob'].concat(env ? ['--env', env] : []), { stdin: value })
109  if (r.exitCode !== 0) {
110    $.ui.toast('keyfob: ' + (r.stderr || '').trim().slice(0, 160))
111  } else {
112    $.ui.toast('keyfob: stored ' + name)
113    const a = asked.get(name)
114    if (a) {
115      asked.delete(name)
116      tell($, 'keyfob: ' + name + ' is now stored ($' + a.env + '). Go on with what needed it, running the command as `keyfob run ' + name + '=' + a.env + ' -- <command>`.')
117    }
118  }
119  report = null
120  refresh($, false)
121}
122
123function later($) {
124  const name = adding && adding.name
125  close($)
126  if (name && asked.has(name)) {
127    asked.delete(name)
128    tell($, 'keyfob: the person chose not to add ' + name + ' now. Say what cannot run without it, and do not ask for it again this turn.')
129  }
130}
131
132function result(o) {
133  return { result: JSON.stringify(o) }
134}
135
136export function register(on) {
137  on('session.start', async ($, e, next) => {
138    // each registration on its own: a refusal must not stop the rest
139    try { await $.command.register({ name: 'keyfob', description: 'API tokens: what plugins need, what is stored, add one (values never shown)' }) } catch (err) { /* panel unavailable; capture still works */ }
140    try {
141      await $.tool.register({
142        name: 'keyfob_status',
143        // old: ...; the user adds a token by pasting `keyfob: <name> <token>` into the chat.
144        description: 'keyfob holds the API tokens (names only, never values). Returns the storage in use, every secret stored or declared by a plugin, each declared group and whether it is complete, and with live=true whether each service still accepts its secrets. Call it before running a command that needs an API token, or when one fails with 401/403. Run such commands as `keyfob run <group|name> -- <command>`; to get a missing one, call keyfob_request.',
145        inputSchema: { type: 'object', properties: { live: { type: 'boolean', description: 'also ask each service whether its secrets still work (slower)' } } },
146      })
147    } catch (err) { /* tool unavailable */ }
148    try {
149      await $.tool.register({
150        name: 'keyfob_request',
151        description: 'Ask the person for an API key, token or password without it entering the conversation. Opens the /keyfob panel on that secret with a masked field; returns at once. If it answers `opened`, say in one line what to paste, then end your turn: when the person stores it (or puts it off) a prompt from keyfob says so and you go on. `stored` means it is already there: run the command as `keyfob run <name>=<VAR> -- <command>`. Never ask for a secret in chat. Name it `<service>-<kind>` (openreview-password, github-token, openalex-key) and reuse a name keyfob_status already lists.',
152        inputSchema: {
153          type: 'object',
154          required: ['secret', 'reason'],
155          properties: {
156            secret: { type: 'string', description: 'the secret\'s name: lowercase, `<service>-<kind>`' },
157            env: { type: 'string', description: 'the environment variable a command reads it from (default: the name upper-cased)' },
158            reason: { type: 'string', description: 'one line the person sees: what it is for' },
159            obtain: { type: 'string', description: 'where to get one: a URL or a sentence' },
160          },
161        },
162      })
163    } catch (err) { /* tool unavailable */ }
164    loadPatterns($)
165    json($, ['doctor', '--json']).then((d) => {
166      const n = (d.plaintext || []).length
167      if (n) $.ui.toast('keyfob: ' + n + ' plaintext secret' + (n > 1 ? 's' : '') + ' in shell or Claude settings; /keyfob')
168    }).catch(() => {})
169    return next(e)
170  })
171
172  on('prompt.submit', async ($, e, next) => {
173    const text = e.text || ''
174    if (!LINE.test(text) && !tokens.some((r) => r.test(text))) return next(e)
175    let c
176    try {
177      const r = await kf($, ['capture'], { stdin: text })
178      if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().slice(0, 200) || 'exit ' + r.exitCode)
179      c = JSON.parse(r.stdout)
180    } catch (err) {
181      return { drop: 'keyfob could not store the token in this message, so the message was not sent: ' + String(err && err.message ? err.message : err) }
182    }
183    const names = (c.stored || []).map((s) => s.name).concat((c.pending || []).map((p) => p.name))
184    if (names.length) $.ui.toast('keyfob: stored ' + names.join(', '))
185    report = null
186    return next({ ...e, text: c.text, context: [...(e.context || []), captureNotes(c)] })
187  }).catch(($, e, next) => next.called ? next(e) : { drop: 'keyfob failed while checking this message for tokens, so it was not sent. Send it again, or store the token with /keyfob.' })
188
189  on('command.run', { command: 'keyfob' }, async ($) => {
190    await $.ui.open({ id: PANE, title: 'keyfob', focus: true })
191    refresh($, false)
192    return {}
193  })
194
195  on('ui.close', async ($, e, next) => {
196    if (e.id === PANE) { secret = ''; adding = null; naming = false }
197    return next(e)
198  }).catch(($, e, next) => (next.called ? undefined : next(e)))   // a failure here must never keep a pane open
199
200  on('tool.call', { tool: 'mcp__keyfob__keyfob_status' }, async ($, e) => {
201    try {
202      const [check, ls] = await Promise.all([json($, ['check', '--json'].concat(e.live ? ['--live'] : []), e.live ? 120000 : 60000), json($, ['ls', '--json'])])
203      return { result: JSON.stringify({ storage: ls.backend, secrets: (ls.secrets || []).map((s) => ({ name: s.name, stored: s.stored, env: s.env, declared_by: s.declared_by, obtain: s.obtain })), groups: check.groups, missing: check.missing, live: check.live, problems: check.problems, how: 'keyfob run <group|name> -- <command>; for a missing one call keyfob_request (the person may also open /keyfob)' }) }
204    } catch (err) {
205      return { result: JSON.stringify({ error: String(err && err.message ? err.message : err) }) }
206    }
207  }).catch(($, e, next) => next.called ? next(e) : { result: JSON.stringify({ error: 'keyfob_status failed; run `keyfob check` in Bash' }) })
208
209  on('tool.call', { tool: 'mcp__keyfob__keyfob_request' }, async ($, e) => {
210    const name = String(e.secret || '').trim()
211    const want = String(e.env || '').trim()
212    const reason = String(e.reason || '').split(/\s+/).join(' ').trim()
213    const obtain = String(e.obtain || '').trim()
214    if (!NAME_RE.test(name)) return result({ error: 'secret: lowercase letters, digits, . _ - (e.g. openreview-password)' })
215    if (want && !ENV_RE.test(want)) return result({ error: 'env: an environment variable name such as OPENREVIEW_PASSWORD' })
216    if (!reason) return result({ error: 'reason: one line the person sees, saying what it is for' })
217    const ls = await json($, ['ls', '--json'])
218    const row = (ls.secrets || []).find((s) => s.name === name)
219    if (row && row.stored) return result({ status: 'stored', env: row.env, run: 'keyfob run ' + name + '=' + row.env + ' -- <command>' })
220    const req = await json($, ['request', name, '--reason', reason, '--json'].concat(want ? ['--env', want] : []).concat(obtain ? ['--obtain', obtain] : []))
221    const env = req.env || want
222    asked.set(name, { env })
223    naming = false
224    secret = ''
225    adding = { name, env, why: req.declared_by && req.declared_by !== 'requested' ? 'for ' + req.declared_by : 'asked by Claude: ' + reason }
226    report = null
227    const opened = await $.ui.open({ id: PANE, title: 'keyfob', focus: true })
228    refresh($, false)
229    if (!opened || !opened.isPlaced) {
230      $.ui.toast('keyfob: Claude asks for ' + name + '; type /keyfob to add it')
231      return result({ status: 'waiting', env, tell_user: 'Type /keyfob, paste the value for ' + name + ' into its field and press store (the terminal is too narrow to open the panel by itself).' })
232    }
233    return result({ status: 'opened', env, tell_user: 'The /keyfob panel is open on ' + name + ': paste the value into the field and press store; it never becomes a message.', then: 'end your turn; a prompt from keyfob says when it is stored' })
234  }).catch(($, e, next) => next.called ? next(e) : result({ error: 'keyfob_request failed; ask the person to open /keyfob and add it there' }))
235
236  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
237    const { Box, Text, Button, Input } = $.ui.resolve(e)
238    const T = (s, props) => Text(Object.assign({ children: [String(s)], wrap: 'truncate-end' }, props || {}))
239    if (!report && !busy && !lastError) refresh($, false)
240    const secrets = report ? report.ls.secrets || [] : []
241    const rank = (s) => (adding && s.name === adding.name ? 0 : s.declared_by === 'requested' ? 1 : 2)
242    const needed = secrets.filter((s) => !s.stored).sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name))
243    const stored = secrets.filter((s) => s.stored).sort((a, b) => a.name.localeCompare(b.name))
244    const why = (s) => (s.declared_by === 'requested' ? 'asked by Claude: ' + (s.description || '') : s.declared_by ? 'for ' + s.declared_by + (s.description ? ' · ' + s.description : '') : s.note || '')
245
246    const field = (name, env) => Box({ key: 'field-' + name, flexDirection: 'column', marginTop: 1, children: [
247      Box({ gap: 1, children: [
248        Input({
249          key: 'value', label: 'value: ', placeholder: 'paste it here, then Enter', submitLabel: 'store', autoFocus: true,
250          value: BULLET.repeat(secret.length),
251          onInput: (v) => { secret = nextSecret(secret, v); $.ui.invalidate('ui.render') },
252          onSubmit: (v) => { if (v && !String(v).includes(BULLET)) secret = String(v); storeValue($, name, env) },
253        }),
254      ] }),
255      Box({ gap: 1, children: [
256        Button({ key: 'store', label: 'store', variant: 'primary', onPress: () => storeValue($, name, env) }),
257        Button({ key: 'later', label: 'later', onPress: () => later($) }),
258        T(secret.length ? secret.length + ' characters, shown as ' + BULLET : 'nothing typed yet', { color: C.dim }),
259      ] }),
260    ] })
261
262    const row = (s, isStored) => {
263      const open = adding && adding.name === s.name
264      const kids = [
265        Box({ key: 'line-' + s.name, gap: 1, children: [
266          T((isStored ? '✓ ' : '● ') + s.name, { bold: open, color: isStored ? C.ok : C.needed }),
267          T('$' + s.env, { color: C.dim }),
268          Box({ flexGrow: 1, children: [] }),
269          open ? null : Button({ key: (isStored ? 'replace-' : 'add-') + s.name, label: isStored ? 'replace' : 'add', onPress: () => { adding = { name: s.name, env: s.env }; secret = ''; naming = false; $.ui.invalidate('ui.render') } }),
270        ].filter(Boolean) }),
271      ]
272      const w = why(s)
273      if (w) kids.push(T('  ' + w, { color: C.dim }))
274      if (!isStored && s.obtain) kids.push(T('  get one at ' + s.obtain, { color: C.dim }))
275      if (open) kids.push(field(s.name, s.env))
276      return Box({ key: 'row-' + s.name, flexDirection: 'column', marginBottom: 1, children: kids })
277    }
278
279    const card = (key, title, color, children) => Box({
280      key, flexDirection: 'column', borderStyle: 'round', borderColor: color, paddingX: 1, marginTop: 1,
281      children: [T(title, { bold: true, color })].concat(children),
282    })
283
284    const rows = []
285    rows.push(Box({ key: 'head', gap: 1, children: [
286      T('🔑 keyfob', { bold: true, color: C.accent }),
287      T(report ? report.ls.backend + ' · ' + stored.length + ' stored · ' + needed.length + ' needed' + (report.live ? ' · live check ran' : '') : '', { color: C.dim }),
288    ] }))
289    if (busy) rows.push(T('⟳ ' + busy, { color: C.needed }))
290    if (lastError) rows.push(T('✗ ' + lastError, { color: C.bad }))
291
292    // a request for a name no row has yet (ls not read since): its field still opens
293    if (adding && !secrets.some((s) => s.name === adding.name)) {
294      rows.push(card('card-new', adding.isNew ? 'New secret' : 'Needed', C.needed, [
295        T('● ' + adding.name + '   $' + (adding.env || ''), { bold: true, color: C.needed }),
296        adding.why ? T('  ' + adding.why, { color: C.dim }) : null,
297        field(adding.name, adding.env),
298      ].filter(Boolean)))
299    }
300    if (needed.length) rows.push(card('card-needed', 'Needed', C.needed, needed.map((s) => row(s, false))))
301    if (stored.length) rows.push(card('card-stored', 'Stored', C.frame, stored.map((s) => row(s, true))))
302    if (report && !secrets.length && !adding) rows.push(T('Nothing stored or declared yet. Claude asks with keyfob_request; plugins declare theirs in keyfob.json.', { color: C.dim }))
303
304    const groups = report ? Object.entries(report.check.groups || {}) : []
305    if (groups.length) {
306      rows.push(card('card-groups', 'Groups', C.frame, groups.map(([n, g]) => T(
307        (g.complete ? '✓ ' : '✗ ') + n + (g.complete ? '' : '   needs ' + (g.missing || []).join(', ')) + (g.optional_missing && g.optional_missing.length ? '   (optional, absent: ' + g.optional_missing.join(', ') + ')' : ''),
308        { color: g.complete ? C.ok : C.bad }))))
309    }
310
311    if (naming) {
312      rows.push(Box({ key: 'naming', marginTop: 1, children: [Input({
313        key: 'name', label: 'new secret name: ', placeholder: 'e.g. github-token, then Enter', submitLabel: 'next', autoFocus: true,
314        onSubmit: (v) => {
315          const n = String(v || '').trim()
316          if (!NAME_RE.test(n)) { $.ui.toast('keyfob: lowercase letters, digits, . _ -'); return }
317          naming = false
318          const known = secrets.find((s) => s.name === n)
319          adding = { name: n, env: known ? known.env : n.toUpperCase().replace(/[^A-Z0-9]/g, '_'), isNew: !known }
320          secret = ''
321          $.ui.invalidate('ui.render')
322        },
323      })] }))
324    }
325
326    rows.push(Box({ key: 'bar', gap: 1, marginTop: 1, children: [
327      Button({ key: 'new', label: '+ new', onPress: () => { naming = true; adding = null; secret = ''; $.ui.invalidate('ui.render') } }),
328      Button({ key: 'refresh', label: 'refresh', onPress: () => refresh($, false) }),
329      Button({ key: 'live', label: 'live check', onPress: () => refresh($, true) }),
330      Button({ key: 'close', label: 'close', onPress: () => { close($); $.ui.close({ id: PANE }) } }),
331    ] }))
332    rows.push(T('Values never leave this panel · keyfob run <name> -- <command>', { color: C.dim }))
333    return Box({ flexDirection: 'column', children: rows })
334  })
335}
336