SLOPSHOPPER

keyfob

Keep your API keys and tokens out of your Claude Code conversations: tokens pasted as keyfob: lines are stored before the model sees them, Claude asks for a…

newpaneguardcommandtoastprompt
v0.2.0CC-BY-NC-SA-4.0updated 2026-10-09shiran-yu/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

Keep your API keys and tokens out of your Claude Code conversations.

Paste a token as keyfob: <name> <token> and the model sees [stored in keyfob]. When Claude needs a key it asks for it by name, and you paste it into a field that redraws it as bullets. A command gets the key for its own run, and nothing is exported into your shell.

version Claude Code platforms licence

![In Claude Code: a token pasted into the chat is stored and replaced by "[stored in keyfob]" before the model sees it; then Claude asks for the Semantic Scholar key by name, the /keyfob panel opens on it, the key goes into its field and is redrawn as bullets, and Claude carries on with keyfob run. All keys in the recording are made up.](assets/claude-code.gif)

Install

Tell Claude Code:

Install https://github.com/shiran-yu/keyfob

It adds the plugin and asks you to restart. The keyfob binary comes with it: fetched once for your machine and checked against the release's SHA-256.

  1. claude plugin marketplace add shiran-yu/keyfob
  2. claude plugin install keyfob@keyfob
  3. Ask the user to restart Claude Code. On first use the plugin's launcher downloads keyfob-<target>.tar.gz from the matching release and checks it against its .sha256.

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/shiran-yu/keyfob.

Why

A token pasted into Claude Code becomes part of the conversation: the model reads it, and the transcript on disk keeps it. A key exported in ~/.bashrc sits in the environment of every process you start, Claude's shell included, and one env or cat .env prints it into the conversation as well. keyfob keeps the value in the OS keystore (or a 0600 file where there is none) and hands it to one command at a time, so the model never needs to see it. What this covers, and what it does not, is below.

In Claude Code

  • Paste a token and it is gone before the model sees it. Send keyfob: s2-key <token> on a line of its own (optionally followed by the variable name, e.g. S2_API_KEY) and the plugin stores the value before the message is kept; the model and the transcript get keyfob: s2-key [stored in keyfob]. A bare token in a format it recognises (OpenAI, Anthropic, GitHub, Hugging Face, Slack, Google, JWT, AWS access key IDs, Stripe live keys) is stored as pasted-1, pasted-2, … and replaced by [keyfob:pasted-1]. Anything else, such as a password or a value with a space, is sent as typed: use /keyfob for those.
  • Claude asks for a key by name. It calls keyfob_request; /keyfob opens on that secret with a field that redraws what you paste as bullets, you press store, and a prompt tells Claude to go on. The value never becomes a message. You can open /keyfob yourself at any time.
  • A guard on 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 as a shell does, so a word inside a quoted grep pattern is not taken for keyfob get, env and the like.
  • Plugins say what they need. A plugin lists its keys in a keyfob.json; keyfob finds it in every plugin enabled in your user settings, /keyfob shows what is missing and where to get it, and keyfob check --live asks each service a group declares a check for whether its keys still work.

In your terminal

keyfob doctor finds an API key written in plain text in ~/.bashrc; keyfob add stores it without echoing it; keyfob ls lists names, never values; keyfob run hands it to eval.py for that one run; echo shows the shell itself never had it. The key is made up.

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 all but which
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/> [--names a,b] [--apply]preview, then with --apply copy Keychain items another tool wrote as <old/><name>, for the declared names or --names
scrubstdin to stdout with every stored value replaced by [keyfob:<name>]
capture, patterns, guardused by the Claude Code plugin

Where the values live. 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). Choose with 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. One self-contained binary (Rust) for macOS (arm64, x86_64) and Linux (x86_64, aarch64; static musl builds).

For plugin authors: keyfob.json

Put it at your plugin's root. keyfob reads it from every plugin enabled in your user settings (~/.claude/settings.json); 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>. A local (stdio) MCP server that needs a key is started as keyfob run <name> -- <server command> in its MCP config; credentials in a remote server's headers are outside what keyfob can hand over.

What it protects, and what it does not

  • It keeps tokens out of rc files, settings files and the environment of every process. It keeps a token out of the conversation and the transcript when it is pasted as a keyfob: line or in a recognised format, or typed into /keyfob, and the guard refuses the common commands that would print one.
  • It does not filter what a command run under keyfob run prints: a script that prints its key puts it in the conversation. A literal token that Claude writes into a Bash command is already in the transcript when the guard refuses it; the refusal stops it from running, not from being written.
  • 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 any program running as you can read keyfob's items through /usr/bin/security without a dialog, and the Keychain asks only apps that read it directly.
  • A pasted keyfob: line (a token without spaces) or a token in a recognised format is stored and replaced before the message enters the session; anything else is sent as typed. 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.
  • The /keyfob field redraws its content as bullets after each keystroke or paste, so what you type can show for a moment before it is redrawn.

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, VERSION= in plugins/keyfob/bin/keyfob and the README badge, run cargo build so Cargo.lock follows (the release build uses --locked), commit, 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