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…

API tokens for Claude Code, your shell and your scripts, kept out of everyone's way.
0600 file in a 0700 folder (what Claude Code itself does for its own login on Linux).keyfob run openai -- python3 eval.py. Nothing is exported into your shell, so nothing else (and no model) can read it from the environment.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.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.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.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).
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.
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
| Command | Does | ||
|---|---|---|---|
| `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, doctor | look, 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, guard | used 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.
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.
~/.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.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.
hooks/register.js 336 lines1// 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