Secure input window for API keys and passwords: Claude asks, you type into a masked field, the mod writes .env / GitHub Secrets itself. The value never enters…

A Claude Code mod for handing Claude API keys and passwords without pasting them into the chat.
When Claude needs a key, a band appears right above the prompt with a masked field. You paste the key and press Enter. The mod writes it to .env or GitHub Actions secrets on its own. Claude only learns OPENAI_API_KEY saved (51 chars), never the value.
No more "start a local server so I can type the key in a browser".
The story of how this mod was built with Claude, step by step and with screenshots (in Russian): How I taught Claude to take passwords and keys safely.
In a Claude Code terminal session:
/plugin install secret-drop --marketplace aislam23/secret-drop
Answer y to add the marketplace, then pick the user scope. Once installed at the user scope, the mod also works in the desktop app's Code tab.
Claude asks. The mod gives Claude a request_secret tool and tells it to use the tool instead of asking for keys in the chat:
Claude: I need the OpenAI key for the bot. I've opened a secure field above your prompt.
(🔑) Claude asks for a key · the bot calls the OpenAI API ●○ 1/2 [📄 .env in the project folder]
OPENAI_API_KEY platform.openai.com → API keys
Paste the key → [ •••••••••••••••••••• ] [next] Show Skip Cancel
🛡 The value never enters the chat. Claude only learns the name and length.
On the desktop app the band is drawn with SVG in Claude's own colours. In the terminal it is plain text.
After you press Enter on the last key, the mod saves everything and sends Claude a short report:
[secret-drop] The user finished the secure input you requested.
Entered: OPENAI_API_KEY (51 chars), TELEGRAM_BOT_TOKEN (46 chars).
✓ OPENAI_API_KEY, TELEGRAM_BOT_TOKEN → /Users/me/bot/.env
✓ added /.env to .gitignore
✓ OPENAI_API_KEY → GitHub Secrets (me/bot)
You ask. You can also open the field yourself:
/secret OPENAI_API_KEY STRIPE_SECRET_KEY
/secret DEPLOY_TOKEN --github # .env plus this repo's GitHub secrets
/secret DEPLOY_TOKEN --github me/other-repo --no-env
/secret DB_PASSWORD --env-file config/.env.local
Safety net. If you paste a key straight into the chat anyway (OpenAI, Anthropic, ElevenLabs, GitHub, GitLab, AWS, Google, Stripe, Slack, Telegram bot, Supabase, Hugging Face, Groq, xAI, Perplexity, Replicate, npm, Yandex, private keys), the message is not sent. A long token next to words like key, token, password or ключ is caught too, even in an unknown format.
Instead, the band opens right away with the caught key already filled in (as dots) and a name guessed from its format, such as ELEVENLABS_API_KEY. Fix the name if you like and press Enter. The key is saved, and your message comes back into the prompt box with the key's name where the key was: use ELEVENLABS_API_KEY (saved to .env in the project folder). Press Don't save to drop the key instead.
export if it was there, or appends a new one. Values with spaces or special characters get quoted. A new file gets chmod 600. If the file is in a git repo and not ignored, the mod adds it to .gitignore. If it is already committed, it warns you.gh secret set NAME [--repo] [--env] and passes the value through stdin, so it never shows up in the process list. This needs gh to be signed in.In /config → secret-drop:
| Option | Default | |
|---|---|---|
language | auto | en / ru window text. auto follows LANG, falling back to the macOS language list |
guardPrompts | true | block messages that contain keys |
cat .env if you allow it, so say no to that request. The tool's description tells Claude not to.ui.input could see what you type, just as any installed plugin sees your keystrokes. Install mods you trust.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT © Artem Islamov
Мод для Claude Code: передаёт Claude ключи и пароли без вставки в чат. Когда Claude нужен ключ, прямо над полем сообщения появляется плашка со скрытым полем. Вставляешь ключ, жмёшь Enter, и мод сам кладёт его в .env или в GitHub Secrets. Claude узнаёт только «OPENAI_API_KEY сохранён (51 симв.)».
request_secret, а ты можешь открыть окно сам: /secret ИМЯ [--github].ELEVENLABS_API_KEY): жмёшь Enter, и ключ сохранён, а в сообщении вместо ключа стоит его имя..env добавляется в .gitignore. В GitHub значение уходит через stdin gh secret set.Как мод делался вместе с Claude, со скриншотами и выводами про работу с агентом: Как я научил Claude безопасно брать пароли и ключи с помощью мода.
hooks/register.tsx 714 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SecretAsk, SecretRequest } from '../types'
5import {
6 DOT,
7 ENV_NAME,
8 STRINGS,
9 applyMaskedEdit,
10 githubNameProblem,
11 redactSecrets,
12 scrub,
13 upsertEnv,
14} from './lib'
15import type { Lang, Strings } from './lib'
16import { CUE_H, FOOT_H, HEADER_H, cueSvg, cueWidth, footSvg, headerSvg, reportSvg, toneOf } from './look'
17import type { Destination } from './look'
18
19// The pane id of 0.1.0, closed on load so an old window does not linger.
20const OLD_PANE = 'secret-drop'
21
22const request = atom({ plugin: 'secret-drop', key: 'request' } as const, null)
23const rev = atom({ plugin: 'secret-drop', key: 'rev' } as const, 0)
24const isRevealed = atom({ plugin: 'secret-drop', key: 'isRevealed' } as const, false)
25
26type Engine = EngineInterface
27
28// Prompt origins a person typed: the terminal, the phone bridge, the desktop app (an SDK host).
29const HUMAN_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
30
31type StartArgs = {
32 secrets: SecretAsk[]
33 reason?: string
34 saveTo?: Array<'env' | 'github'>
35 envFile?: string
36 githubRepo?: string
37 githubEnvironment?: string
38 from: SecretRequest['from']
39 /** Values already known (the guard caught them), by secret index. */
40 prefill?: string[]
41 draft?: string
42}
43
44const TOOL_DESCRIPTION = `Ask the user for secrets (API keys, tokens, passwords) through a masked input window, then save them to a .env file and/or GitHub Actions secrets WITHOUT the values ever entering this conversation.
45
46Use it whenever a task needs a secret from the user: never ask them to paste a secret into the chat, and never spin up a local server or form to collect one.
47
48The call returns at once and the window stays open for the user. End your turn right after calling it. When the user is done you receive a message starting with "[secret-drop]" listing what was saved where (names and lengths only). Afterwards refer to the values through the environment (process.env.NAME, $NAME, \${{ secrets.NAME }}); never print, cat or echo them.`
49
50const TOOL_SCHEMA = {
51 type: 'object',
52 properties: {
53 secrets: {
54 type: 'array',
55 minItems: 1,
56 maxItems: 10,
57 description: 'The secrets to ask for, in order.',
58 items: {
59 type: 'object',
60 properties: {
61 name: { type: 'string', description: 'Variable name, e.g. OPENAI_API_KEY.' },
62 hint: {
63 type: 'string',
64 description: "Shown under the name: where to get it or what it looks like, in the user's language.",
65 },
66 },
67 required: ['name'],
68 },
69 },
70 reason: { type: 'string', description: "One short sentence, in the user's language: why it is needed." },
71 save_to: {
72 type: 'array',
73 items: { type: 'string', enum: ['env', 'github'] },
74 description: 'Where to save: a dotenv file, GitHub Actions secrets, or both. Default ["env"].',
75 },
76 env_file: {
77 type: 'string',
78 description: 'The dotenv file, relative to the working directory or absolute. Default ".env".',
79 },
80 github_repo: { type: 'string', description: 'owner/repo for GitHub secrets. Default: the repository of the working directory.' },
81 github_environment: { type: 'string', description: 'A GitHub deployment environment, for environment secrets.' },
82 },
83 required: ['secrets'],
84}
85
86// The values live here and only here, for as long as the window is open.
87let buffer = ''
88const values = new Map<string, string>()
89// Keys the prompt guard caught, waiting in the band to be saved, by secret index.
90const prefill = new Map<number, string>()
91let S: Strings = STRINGS.en
92
93const namesOf = (req: SecretRequest) =>
94 req.secrets
95 .slice(req.index)
96 .map(ask => ask.name)
97 .join(', ')
98
99// The band's requestId, as its last drawing saw it: what $.ui.focus addresses.
100let bandId: string | undefined
101
102const inputKey = (req: SecretRequest) => `value-${req.id}-${req.index}`
103const nameKey = (req: SecretRequest) => `name-${req.id}-${req.index}`
104
105async function focusBand($: Engine, req: SecretRequest) {
106 if (bandId === undefined) return
107 await $.ui.focus({ requestId: bandId, key: inputKey(req) }).catch(() => undefined)
108}
109
110async function absolutePath($: Engine, path: string) {
111 if (path.startsWith('/')) return path
112 if (path.startsWith('~/')) return `${(await $.env.get('HOME')) ?? ''}${path.slice(1)}`
113 const cwd = await $.session.cwd()
114
115 return `${cwd}/${path.replace(/^\.\//, '')}`
116}
117
118async function envLabelOf($: Engine, file: string) {
119 const cwd = await $.session.cwd()
120 if (file === `${cwd}/.env`) return S.envProject
121 if (file.startsWith(`${cwd}/`)) return file.slice(cwd.length + 1)
122 const home = await $.env.get('HOME')
123
124 return home && file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file
125}
126
127async function start($: Engine, args: StartArgs): Promise<SecretRequest | string> {
128 const current = await read($, request)
129 if (current && current.phase !== 'finished') {
130 return `Another secret request is still open (${namesOf(current)}). Wait for the user to finish it.`
131 }
132 const saveTo = args.saveTo && args.saveTo.length > 0 ? args.saveTo : ['env']
133 const seen = new Set<string>()
134 const secrets: SecretAsk[] = []
135 for (const ask of args.secrets) {
136 const name = String(ask?.name ?? '').trim()
137 if (!ENV_NAME.test(name)) return `"${name}" is not a valid variable name (letters, digits and _, not starting with a digit).`
138 const ghProblem = saveTo.includes('github') ? githubNameProblem(name) : undefined
139 if (ghProblem) return `"${name}" cannot be a GitHub secret: ${ghProblem}.`
140 if (seen.has(name)) continue
141 seen.add(name)
142 secrets.push({
143 name,
144 ...(ask.hint ? { hint: String(ask.hint) } : {}),
145 ...(ask.placeholder ? { placeholder: ask.placeholder } : {}),
146 })
147 }
148 if (secrets.length === 0) return 'Name at least one secret.'
149
150 const envFile = saveTo.includes('env') ? await absolutePath($, args.envFile ?? '.env') : undefined
151 const req: SecretRequest = {
152 id: `${Date.now()}`,
153 secrets,
154 reason: args.reason,
155 envFile,
156 envLabel: envFile ? await envLabelOf($, envFile) : undefined,
157 github: saveTo.includes('github')
158 ? { repo: args.githubRepo, environment: args.githubEnvironment }
159 : undefined,
160 from: args.from,
161 ...(args.draft !== undefined ? { draft: args.draft } : {}),
162 index: 0,
163 captured: [],
164 skipped: [],
165 phase: 'input',
166 report: [],
167 }
168 values.clear()
169 prefill.clear()
170 args.prefill?.forEach((value, i) => prefill.set(i, value))
171 buffer = prefill.get(0) ?? ''
172 await update($, isRevealed, () => false)
173 await update($, request, () => req)
174 void focusBand($, req)
175
176 return req
177}
178
179async function ensureIgnored($: Engine, file: string, report: string[]) {
180 const dir = file.slice(0, file.lastIndexOf('/')) || '/'
181 const top = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'])
182 if (top.exitCode !== 0) return
183 const root = top.stdout.trim()
184 const rel = file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file
185 const tracked = await $.process.run(['git', '-C', root, 'ls-files', '--error-unmatch', rel])
186 if (tracked.exitCode === 0) {
187 report.push(S.tracked(rel))
188 return
189 }
190 const ignored = await $.process.run(['git', '-C', root, 'check-ignore', '-q', rel])
191 if (ignored.exitCode === 0) return
192 const gitignore = `${root}/.gitignore`
193 const old = (await $.fs.exists(gitignore)) ? String(await $.fs.read(gitignore)) : ''
194 await $.fs.write(gitignore, `${old}${old && !old.endsWith('\n') ? '\n' : ''}/${rel}\n`)
195 report.push(S.gitignored(`/${rel}`))
196}
197
198async function save($: Engine, req: SecretRequest): Promise<string[]> {
199 const report: string[] = []
200 if (values.size === 0) return [S.nothing]
201 const names = [...values.keys()].join(', ')
202
203 if (req.envFile) {
204 try {
205 const existed = await $.fs.exists(req.envFile)
206 const old = existed ? String(await $.fs.read(req.envFile)) : ''
207 await $.fs.write(req.envFile, upsertEnv(old, values))
208 if (!existed) await $.process.run(['chmod', '600', req.envFile])
209 report.push(S.savedEnv(names, req.envFile))
210 await ensureIgnored($, req.envFile, report)
211 } catch (error) {
212 report.push(S.failedEnv(scrub(String(error), values.values())))
213 }
214 }
215
216 if (req.github) {
217 let repo = req.github.repo
218 if (!repo) {
219 const view = await $.process
220 .run(['gh', 'repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'])
221 .catch(() => undefined)
222 repo = view?.exitCode === 0 ? view.stdout.trim() : undefined
223 }
224 for (const [name, value] of values) {
225 const argv = ['gh', 'secret', 'set', name]
226 if (repo) argv.push('--repo', repo)
227 if (req.github.environment) argv.push('--env', req.github.environment)
228 try {
229 const ran = await $.process.run(argv, { stdin: value, timeoutMs: 60_000 })
230 report.push(
231 ran.exitCode === 0
232 ? S.savedGh(name, repo ?? '?')
233 : S.failedGh(name, scrub(ran.stderr.trim().split('\n')[0] ?? `exit ${ran.exitCode}`, values.values())),
234 )
235 } catch (error) {
236 report.push(S.failedGh(name, scrub(String(error), values.values())))
237 }
238 }
239 }
240
241 return report
242}
243
244async function tellModel($: Engine, req: SecretRequest, lengths: Map<string, number>, isCancelled: boolean) {
245 if (req.from === 'guard' && isCancelled) return
246 const lines = ['[secret-drop]']
247 if (isCancelled) {
248 lines.push('The user cancelled the secure input. Nothing was saved.')
249 } else {
250 lines.push(
251 req.from === 'model'
252 ? 'The user finished the secure input you requested.'
253 : req.from === 'guard'
254 ? 'The user pasted a key into a message; secret-drop kept the message from being sent and the user stored the key instead.'
255 : 'The user stored secrets through /secret.',
256 )
257 const got = req.captured.map(name => `${name} (${lengths.get(name) ?? 0} chars)`)
258 if (got.length > 0) lines.push(`Entered: ${got.join(', ')}.`)
259 if (req.skipped.length > 0) lines.push(`Skipped by the user: ${req.skipped.join(', ')}.`)
260 lines.push(...req.report)
261 lines.push('The values were never shown in this conversation. Use them through the environment; do not print or read them back.')
262 }
263 const text = lines.join('\n')
264 if (req.from === 'model') {
265 void $.prompt.submit({ text })
266 } else {
267 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
268 }
269}
270
271/** Puts the saved keys' names where the guard cut them out, if the person left the message as it was. */
272async function restoreDraft($: Engine, req: SecretRequest) {
273 if (req.from !== 'guard' || req.draft === undefined) return
274 const box = await $.prompt.read()
275 if (box.text !== req.draft) return
276 let text = req.draft
277 const where = req.envLabel ?? req.envFile ?? '.env'
278 for (const ask of req.secrets) {
279 if (ask.placeholder && req.captured.includes(ask.name)) text = text.replace(ask.placeholder, S.savedAs(ask.name, where))
280 }
281 if (text !== req.draft) await $.prompt.fill({ text })
282}
283
284async function renameAsk($: Engine, name: string) {
285 const clean = name.trim().replace(/[\s-]+/g, '_')
286 await update($, request, req => {
287 if (!req || req.phase !== 'input') return req
288 return { ...req, secrets: req.secrets.map((ask, i) => (i === req.index ? { ...ask, name: clean } : ask)) }
289 })
290}
291
292async function finish($: Engine, isCancelled: boolean) {
293 const req = await read($, request)
294 if (!req || req.phase !== 'input') return
295 $.ui.status(undefined)
296 const lengths = new Map([...values].map(([name, value]) => [name, value.length]))
297 if (isCancelled) {
298 buffer = ''
299 values.clear()
300 prefill.clear()
301 await update($, request, () => null)
302 await tellModel($, req, lengths, true)
303 return
304 }
305 await update($, request, () => ({ ...req, phase: 'saving' as const }))
306 const report = await save($, req)
307 buffer = ''
308 values.clear()
309 prefill.clear()
310 const done: SecretRequest = { ...req, phase: 'finished', report }
311 await update($, request, () => done)
312 await tellModel($, done, lengths, false)
313 await restoreDraft($, done)
314 $.clock.after(8_000, () => {
315 void (async () => {
316 const now = await read($, request)
317 if (now?.id === done.id && now.phase === 'finished') await update($, request, () => null)
318 })()
319 })
320}
321
322async function advance($: Engine, isTaken: boolean) {
323 const req = await read($, request)
324 if (!req || req.phase !== 'input') return
325 const ask = req.secrets[req.index]
326 if (!ask) return
327 if (isTaken && buffer === '') return
328 if (isTaken && !ENV_NAME.test(ask.name)) {
329 $.ui.toast(S.badName)
330 return
331 }
332 if (isTaken) values.set(ask.name, buffer)
333 buffer = ''
334 const next: SecretRequest = {
335 ...req,
336 captured: isTaken ? [...req.captured, ask.name] : req.captured,
337 skipped: isTaken ? req.skipped : [...req.skipped, ask.name],
338 }
339 await update($, isRevealed, () => false)
340 if (req.index + 1 < req.secrets.length) {
341 const moved = { ...next, index: req.index + 1 }
342 buffer = prefill.get(moved.index) ?? ''
343 await update($, request, () => moved)
344 void focusBand($, moved)
345 return
346 }
347 await update($, request, () => next)
348 await finish($, false)
349}
350
351export const register: Register = (on, options) => {
352 const guardPrompts = options.guardPrompts !== false
353
354 on('session.start', async ($, e, next) => {
355 const setting = options.language
356 if (setting === 'en' || setting === 'ru') {
357 S = STRINGS[setting]
358 } else {
359 const locale = [await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')].find(Boolean)
360 let lang: Lang = locale?.toLowerCase().startsWith('ru') ? 'ru' : 'en'
361 if (!locale) {
362 const apple = await $.process
363 .run(['defaults', 'read', '-g', 'AppleLanguages'], { timeoutMs: 3_000 })
364 .catch(() => undefined)
365 if (apple && /\bru\b/i.test(apple.stdout)) lang = 'ru'
366 }
367 S = STRINGS[lang]
368 }
369
370 await $.tool.register({
371 name: 'request_secret',
372 description: TOOL_DESCRIPTION,
373 inputSchema: TOOL_SCHEMA,
374 isDeferred: false,
375 })
376 await $.command.register({
377 name: 'secret',
378 description: 'Enter API keys or passwords in a masked window; they go to .env or GitHub Secrets, never into the chat',
379 argumentHint: 'NAME [NAME…] [--github [owner/repo]] [--env-file path] [--no-env]',
380 })
381
382 await $.ui.close({ id: OLD_PANE })
383
384 // A reload starts with no values in memory: an open request asks again from the first key.
385 const stale = await read($, request)
386 if (stale && stale.phase === 'input') {
387 await update($, request, () => ({ ...stale, index: 0, captured: [], skipped: [] }))
388 } else if (stale) {
389 await update($, request, () => null)
390 $.ui.status(undefined)
391 }
392
393 return next(e)
394 })
395
396 on('tool.call', { tool: 'mcp__secret-drop__request_secret' }, async ($, e) => {
397 const input = e as unknown as {
398 secrets?: SecretAsk[]
399 reason?: string
400 save_to?: Array<'env' | 'github'>
401 env_file?: string
402 github_repo?: string
403 github_environment?: string
404 }
405 if (!Array.isArray(input.secrets) || input.secrets.length === 0) return { deny: 'Pass `secrets`: [{ name }].' }
406 const started = await start($, {
407 secrets: input.secrets,
408 reason: input.reason,
409 saveTo: input.save_to,
410 envFile: input.env_file,
411 githubRepo: input.github_repo,
412 githubEnvironment: input.github_environment,
413 from: 'model',
414 })
415 if (typeof started === 'string') return { deny: started }
416
417 const where = [
418 started.envFile ? `dotenv file ${started.envFile}` : undefined,
419 started.github ? `GitHub secrets${started.github.repo ? ` of ${started.github.repo}` : ''}` : undefined,
420 ].filter(Boolean)
421
422 return {
423 result: `A masked input window is open for the user for ${started.secrets
424 .map(ask => ask.name)
425 .join(', ')} (saving to ${where.join(' and ')}). You will not see the values. End your turn now: do not poll and do not ask the user to paste anything into the chat. A "[secret-drop]" message will tell you when they are saved.`,
426 }
427 })
428
429 on('command.run', { command: 'secret' }, async ($, e) => {
430 const tokens = e.args.split(/\s+/).filter(Boolean)
431 const names: string[] = []
432 const saveTo = new Set<'env' | 'github'>(['env'])
433 let envFile: string | undefined
434 let githubRepo: string | undefined
435 for (let i = 0; i < tokens.length; i++) {
436 const token = tokens[i] ?? ''
437 if (token === '--github' || token === '--gh') {
438 saveTo.add('github')
439 const repo = tokens[i + 1]
440 if (repo && !repo.startsWith('--') && repo.includes('/')) {
441 githubRepo = repo
442 i++
443 }
444 } else if (token === '--env-file') {
445 envFile = tokens[++i]
446 } else if (token === '--no-env') {
447 saveTo.delete('env')
448 } else {
449 names.push(token)
450 }
451 }
452
453 if (names.length === 0) {
454 const pending = await read($, request)
455 if (pending && pending.phase === 'input') {
456 await focusBand($, pending)
457 return { text: `secret-drop: ${S.bandWaiting(namesOf(pending))}` }
458 }
459 return { text: 'Usage: /secret NAME [NAME…] [--github [owner/repo]] [--env-file path] [--no-env]' }
460 }
461 if (saveTo.size === 0) return { text: 'secret-drop: nowhere to save (--no-env without --github).' }
462
463 const started = await start($, {
464 secrets: names.map(name => ({ name })),
465 saveTo: [...saveTo],
466 envFile,
467 githubRepo,
468 from: 'person',
469 })
470 if (typeof started === 'string') return { text: `secret-drop: ${started}` }
471
472 return { text: `secret-drop: ${started.secrets.map(ask => ask.name).join(', ')}` }
473 })
474
475 if (guardPrompts) {
476 on('prompt.submit', async ($, e, next) => {
477 // only what a person typed: agents' notifications, routines and other sessions pass untouched
478 if (!HUMAN_ORIGINS.has(e.origin.kind)) return next(e)
479 const { found, text } = redactSecrets(e.text)
480 if (found.length === 0) return next(e)
481 $.clock.after(150, () => void $.prompt.fill({ text }))
482 // the same guessed name twice gets a number, so neither key is dropped
483 const used = new Map<string, number>()
484 const secrets = found.map(item => {
485 const n = (used.get(item.name) ?? 0) + 1
486 used.set(item.name, n)
487 return { name: n > 1 ? `${item.name}_${n}` : item.name, hint: S.guardHint(item.kind), placeholder: item.placeholder }
488 })
489 await start($, { secrets, from: 'guard', prefill: found.map(item => item.value), draft: text })
490
491 return { drop: S.leaked([...new Set(found.map(item => item.kind))].join(', ')) }
492 })
493 }
494
495 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
496 bandId = e.requestId
497 const req = await read($, request)
498 await read($, rev)
499 const revealed = await read($, isRevealed)
500 if (!req || e.props.hasSurvey || e.surface === 'mobile') return next(e)
501 const table = $.ui.resolve(e)
502 const { Box, Text, Button, Input } = table
503 // the terminal draws no Svg: it gets the text layout below
504 const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : null
505 // the desktop reports about 8 CSS pixels per column
506 const W = Math.max(320, Math.min(1400, (e.props.bodyColumns || 100) * 8))
507 const shownLine = (line: string) =>
508 req.envFile && req.envLabel ? line.split(req.envFile).join(req.envLabel) : line
509
510 if (Svg && req.phase !== 'input') {
511 const lines = req.phase === 'saving' ? [] : req.report.map(line => ({ text: shownLine(line), tone: toneOf(line) }))
512 return (
513 <Box flexDirection="row" alignItems="flex-start" columnGap={1}>
514 <Svg
515 source={reportSvg(S.saving, lines, W - 40, req.phase === 'saving')}
516 alt={req.phase === 'saving' ? S.saving : `${S.done}: ${lines.map(line => line.text).join('; ')}`}
517 width={W - 40}
518 />
519 {req.phase === 'finished' && (
520 <Button key="close" plain dimColor label="✕" role="dismiss" onPress={() => void update($, request, () => null)} />
521 )}
522 </Box>
523 )
524 }
525
526 if (Svg) {
527 const ask = req.secrets[req.index]
528 if (!ask) return next(e)
529 const count = req.secrets.length
530 const destinations: Destination[] = []
531 if (req.envFile) destinations.push({ kind: 'env', label: req.envLabel ?? req.envFile })
532 if (req.github) destinations.push({ kind: 'github', label: S.ghTo(req.github.repo, req.github.environment) })
533 const isGuard = req.from === 'guard'
534 const cueW = isGuard ? Math.max(cueWidth(S.nameCue), cueWidth(S.keyCue)) : cueWidth(S.cue)
535 const look = {
536 title: isGuard ? S.guardTitle : S.asks,
537 reason: req.reason,
538 name: ask.name,
539 hint: ask.hint,
540 step: { index: req.index, count },
541 destinations,
542 }
543 return (
544 <Box flexDirection="column" rowGap={1}>
545 <Svg
546 key={`head-${req.id}-${req.index}-${W}`}
547 source={headerSvg(look, W)}
548 alt={`${look.title}: ${ask.name}${ask.hint ? `, ${ask.hint}` : ''} → ${destinations.map(d => d.label).join(', ')}`}
549 width={W}
550 height={HEADER_H}
551 />
552 {isGuard && (
553 <Box flexDirection="row" alignItems="center" columnGap={1}>
554 <Svg key={`cue-name-${cueW}`} source={cueSvg(S.nameCue, cueW)} alt={S.nameCue} width={cueW} height={CUE_H} />
555 <Box>
556 <Input
557 key={nameKey(req)}
558 value={ask.name}
559 submitLabel={S.next}
560 onInput={value => void renameAsk($, value)}
561 onSubmit={value => {
562 void renameAsk($, value).then(() => focusBand($, req))
563 }}
564 />
565 </Box>
566 </Box>
567 )}
568 <Box flexDirection="row" alignItems="center" columnGap={1}>
569 <Svg
570 key={`cue-${isGuard ? 'key' : 'ask'}-${cueW}`}
571 source={cueSvg(isGuard ? S.keyCue : S.cue, cueW)}
572 alt={isGuard ? S.keyCue : S.cue}
573 width={cueW}
574 height={CUE_H}
575 />
576 <Box>
577 <Input
578 key={inputKey(req)}
579 placeholder={S.placeholder}
580 value={revealed ? buffer : DOT.repeat(buffer.length)}
581 submitLabel={req.index === count - 1 ? S.save : S.next}
582 autoFocus
583 onInput={value => {
584 buffer = revealed ? value : applyMaskedEdit(buffer, value)
585 void update($, rev, n => n + 1)
586 }}
587 onSubmit={value => {
588 buffer = revealed ? value : applyMaskedEdit(buffer, value)
589 void advance($, true)
590 }}
591 />
592 </Box>
593 <Box flexGrow={1} />
594 <Button
595 key="reveal"
596 plain
597 dimColor
598 label={revealed ? S.hide : S.show}
599 onPress={() => void update($, isRevealed, shown => !shown)}
600 />
601 {count > 1 && <Button key="skip" plain dimColor label={S.skip} onPress={() => void advance($, false)} />}
602 <Button
603 key="cancel"
604 plain
605 dimColor
606 label={isGuard ? S.dontSave : S.cancel}
607 role="dismiss"
608 onPress={() => void finish($, true)}
609 />
610 </Box>
611 <Svg key={`foot-${W}`} source={footSvg(S.footer, W)} alt={S.footer} width={W} height={FOOT_H} />
612 </Box>
613 )
614 }
615
616 if (req.phase !== 'input') {
617 return (
618 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
619 <Box flexDirection="column">
620 {req.phase === 'saving' ? (
621 <Text dimColor>🔐 {S.saving}</Text>
622 ) : (
623 req.report.map(line => (
624 <Text
625 wrap="truncate-middle"
626 color={line.startsWith('✗') ? 'red' : line.startsWith('⚠') ? 'yellow' : 'green'}
627 >
628 {line}
629 </Text>
630 ))
631 )}
632 </Box>
633 {req.phase === 'finished' && (
634 <Button key="close" label={S.close} role="dismiss" onPress={() => void update($, request, () => null)} />
635 )}
636 </Box>
637 )
638 }
639
640 const ask = req.secrets[req.index]
641 if (!ask) return next(e)
642 const count = req.secrets.length
643 const isLast = req.index === count - 1
644 const where = [
645 req.envFile ? (req.envLabel ?? req.envFile) : undefined,
646 req.github ? S.ghTo(req.github.repo, req.github.environment) : undefined,
647 ].filter((line): line is string => line !== undefined)
648 const about = [req.reason, ask.hint].filter(Boolean).join(' · ')
649 const isGuard = req.from === 'guard'
650
651 return (
652 <Box flexDirection="column" rowGap={1}>
653 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
654 <Text wrap="truncate-end">
655 <Text bold>🔐 {isGuard ? S.guardTitle : S.asks} </Text>
656 <Text bold color="cyan">
657 {ask.name}
658 </Text>
659 {count > 1 ? <Text dimColor> · {S.of(req.index + 1, count)}</Text> : ''}
660 </Text>
661 <Text dimColor wrap="truncate-middle">
662 → {where.join(' · ')}
663 </Text>
664 </Box>
665 {about !== '' && (
666 <Text dimColor wrap="truncate-end">
667 {about}
668 </Text>
669 )}
670 {isGuard && (
671 <Input
672 key={nameKey(req)}
673 label={S.nameCue}
674 value={ask.name}
675 submitLabel={S.next}
676 onInput={value => void renameAsk($, value)}
677 onSubmit={value => {
678 void renameAsk($, value).then(() => focusBand($, req))
679 }}
680 />
681 )}
682 <Box flexDirection="row" columnGap={1} alignItems="center">
683 <Box flexGrow={1}>
684 <Input
685 key={inputKey(req)}
686 label={isGuard ? S.keyCue : undefined}
687 placeholder={S.placeholder}
688 value={revealed ? buffer : DOT.repeat(buffer.length)}
689 submitLabel={isLast ? S.save : S.next}
690 autoFocus
691 onInput={value => {
692 buffer = revealed ? value : applyMaskedEdit(buffer, value)
693 void update($, rev, n => n + 1)
694 }}
695 onSubmit={value => {
696 buffer = revealed ? value : applyMaskedEdit(buffer, value)
697 void advance($, true)
698 }}
699 />
700 </Box>
701 <Button
702 key="reveal"
703 label={revealed ? S.hide : S.show}
704 onPress={() => void update($, isRevealed, shown => !shown)}
705 />
706 {count > 1 && <Button key="skip" label={S.skip} onPress={() => void advance($, false)} />}
707 <Button key="cancel" label={isGuard ? S.dontSave : S.cancel} role="dismiss" onPress={() => void finish($, true)} />
708 </Box>
709 <Text dimColor>🔒 {S.footer}</Text>
710 </Box>
711 )
712 })
713}
714hooks/lib.ts 227 lines1// Pure helpers: no `$`, so the tests can call them directly.
2
3export const DOT = '•'
4
5/**
6 * The Input shows dots, so each edit arrives as dots plus whatever the person
7 * typed or pasted. Dots at the head and tail stand for characters kept from
8 * the real value; the rest is new text.
9 */
10export function applyMaskedEdit(real: string, shown: string): string {
11 let lead = 0
12 while (lead < shown.length && shown[lead] === DOT) lead++
13 let trail = 0
14 while (trail < shown.length - lead && shown[shown.length - 1 - trail] === DOT) trail++
15 lead = Math.min(lead, real.length)
16 trail = Math.min(trail, real.length - lead)
17 const middle = shown.slice(lead, shown.length - trail).split(DOT).join('')
18
19 return real.slice(0, lead) + middle + real.slice(real.length - trail)
20}
21
22export const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
23
24/** GitHub refuses names that start with GITHUB_ or a digit. */
25export function githubNameProblem(name: string): string | undefined {
26 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return 'letters, digits and _ only, not starting with a digit'
27 if (/^GITHUB_/i.test(name)) return 'must not start with GITHUB_'
28
29 return undefined
30}
31
32/**
33 * Single quotes are literal for dotenv (node and python) and for `source`,
34 * so they are the safe wrapper; a value holding a quote or a newline falls
35 * back to double quotes with \n escapes.
36 */
37export function quoteEnv(value: string): string {
38 if (/^[A-Za-z0-9_\-.\/:+@,~%=]*$/.test(value)) return value
39 if (!/['\n\r]/.test(value)) return `'${value}'`
40
41 return `"${value.replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n')}"`
42}
43
44/** Sets each name in a dotenv text: an existing line is replaced in place. */
45export function upsertEnv(text: string, values: ReadonlyMap<string, string>): string {
46 const lines = text === '' ? [] : text.replace(/\n$/, '').split('\n')
47 for (const [name, value] of values) {
48 const matcher = new RegExp(`^\\s*(export\\s+)?${name}\\s*=`)
49 const index = lines.findIndex(line => matcher.test(line))
50 if (index >= 0) {
51 const exported = /^\s*export\s+/.test(lines[index] ?? "") ? 'export ' : ''
52 lines[index] = `${exported}${name}=${quoteEnv(value)}`
53 } else {
54 lines.push(`${name}=${quoteEnv(value)}`)
55 }
56 }
57
58 return lines.join('\n') + '\n'
59}
60
61/** Well-known token shapes, tuned to rarely fire on ordinary text. */
62const PATTERNS: ReadonlyArray<{ kind: string; name: string; re: RegExp }> = [
63 { kind: 'Anthropic', name: 'ANTHROPIC_API_KEY', re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
64 { kind: 'OpenAI', name: 'OPENAI_API_KEY', re: /sk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/g },
65 { kind: 'GitHub', name: 'GITHUB_TOKEN', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/g },
66 { kind: 'GitLab', name: 'GITLAB_TOKEN', re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
67 { kind: 'AWS', name: 'AWS_ACCESS_KEY_ID', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
68 { kind: 'Google', name: 'GOOGLE_API_KEY', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
69 { kind: 'Stripe', name: 'STRIPE_SECRET_KEY', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
70 { kind: 'ElevenLabs', name: 'ELEVENLABS_API_KEY', re: /\bsk_[A-Za-z0-9]{24,}\b/g },
71 { kind: 'Groq', name: 'GROQ_API_KEY', re: /\bgsk_[A-Za-z0-9]{40,}\b/g },
72 { kind: 'xAI', name: 'XAI_API_KEY', re: /\bxai-[A-Za-z0-9]{40,}\b/g },
73 { kind: 'Perplexity', name: 'PERPLEXITY_API_KEY', re: /\bpplx-[A-Za-z0-9]{40,}\b/g },
74 { kind: 'Replicate', name: 'REPLICATE_API_TOKEN', re: /\br8_[A-Za-z0-9]{30,}\b/g },
75 { kind: 'npm', name: 'NPM_TOKEN', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
76 { kind: 'Yandex', name: 'YANDEX_TOKEN', re: /\b(?:y0_[A-Za-z0-9_-]{40,}|AQVN[A-Za-z0-9_-]{30,})/g },
77 { kind: 'Slack', name: 'SLACK_TOKEN', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
78 { kind: 'Telegram bot', name: 'TELEGRAM_BOT_TOKEN', re: /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/g },
79 { kind: 'Supabase', name: 'SUPABASE_ACCESS_TOKEN', re: /\bsbp_[a-f0-9]{40}\b/g },
80 { kind: 'Hugging Face', name: 'HF_TOKEN', re: /\bhf_[A-Za-z0-9]{34,}\b/g },
81 { kind: 'private key', name: 'PRIVATE_KEY', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g },
82]
83
84export type Found = { kind: string; name: string; value: string; placeholder: string }
85
86/** Words that say "a secret is next to this", in English and Russian. */
87const SECRET_CONTEXT = /(api|key|token|secret|password|passwd|ключ|ключик|токен|секрет|пароль)/i
88
89/**
90 * A token-like string with no known shape: 20+ characters of letters, digits, `_` or `-`,
91 * holding both letters and at least three digits. Git hashes (40 hex) and UUIDs are left alone.
92 */
93const LOOSE_TOKEN = /(?<![\w/.@-])[A-Za-z0-9][A-Za-z0-9_-]{19,}(?![\w/.@-])/g
94const isLooseSecret = (s: string) =>
95 /[A-Za-z]/.test(s) &&
96 (s.match(/[0-9]/g)?.length ?? 0) >= 3 &&
97 !/^[0-9a-f]{40}$/i.test(s) &&
98 !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s) &&
99 !/^[A-Z0-9_]+$/.test(s) // an UPPER_CASE_NAME is a variable name, not its value
100
101/** Finds token-shaped strings and returns the text with each one cut out. */
102export function redactSecrets(text: string): { found: Found[]; text: string } {
103 const found: Found[] = []
104 let out = text
105 for (const { kind, name, re } of PATTERNS) {
106 out = out.replace(re, value => {
107 const placeholder = `[${kind} key removed]`
108 found.push({ kind, name, value, placeholder })
109 return placeholder
110 })
111 }
112 if (SECRET_CONTEXT.test(out)) {
113 out = out.replace(LOOSE_TOKEN, match => {
114 if (!isLooseSecret(match)) return match
115 found.push({ kind: 'API', name: 'API_KEY', value: match, placeholder: '[key removed]' })
116 return '[key removed]'
117 })
118 }
119
120 return { found, text: out }
121}
122
123/** Replaces any occurrence of the values in a text (a tool's stderr, say). */
124export function scrub(text: string, values: Iterable<string>): string {
125 let out = text
126 for (const value of values) if (value.length >= 4) out = out.split(value).join('***')
127
128 return out
129}
130
131export type Lang = 'en' | 'ru'
132
133export const STRINGS = {
134 en: {
135 title: 'Secret',
136 asks: 'Claude asks for a key',
137 of: (i: number, n: number) => `${i} of ${n}`,
138 why: 'Why',
139 where: 'Saves to',
140 envProject: '.env in the project folder',
141 ghTo: (repo: string | undefined, env: string | undefined) =>
142 `GitHub Secrets · ${repo ?? 'this repo'}${env ? ` (environment ${env})` : ''}`,
143 cue: 'Paste the key',
144 placeholder: 'paste here and press Enter',
145 value: 'Value',
146 next: 'next',
147 save: 'save',
148 show: 'Show',
149 hide: 'Hide',
150 skip: 'Skip',
151 cancel: 'Cancel',
152 close: 'Close',
153 footer: 'The value never enters the chat. Claude only learns the name and length.',
154 saving: 'Saving…',
155 done: 'Done',
156 noInput: 'This surface has no text field: open the session in the terminal or the desktop app.',
157 bandWaiting: (names: string) => `Claude is waiting for ${names}`,
158 bandOpen: 'Enter securely',
159 status: (names: string) => `🔐 waiting for ${names}: /secret opens the window`,
160 toastNotPlaced: 'secret-drop: widen the window or run /secret to enter the key',
161 chars: (n: number) => `${n} chars`,
162 savedEnv: (names: string, path: string) => `✓ ${names} → ${path}`,
163 savedGh: (name: string, repo: string) => `✓ ${name} → GitHub Secrets (${repo})`,
164 failedEnv: (why: string) => `✗ .env not written: ${why}`,
165 failedGh: (name: string, why: string) => `✗ ${name} → GitHub: ${why}`,
166 gitignored: (rel: string) => `✓ added ${rel} to .gitignore`,
167 tracked: (rel: string) => `⚠ ${rel} is already tracked by git: run git rm --cached ${rel}`,
168 nothing: 'Nothing entered.',
169 leaked: (kinds: string) =>
170 `secret-drop: the message held a ${kinds} key, so it was not sent. Save the key in the band above the prompt; the message is back in the box without it.`,
171 guardTitle: 'Caught a key in your message',
172 guardHint: (kind: string) => `${kind} key, already filled in: check the name and press Enter`,
173 nameCue: 'Name',
174 keyCue: 'Key',
175 dontSave: "Don't save",
176 badName: 'secret-drop: a name is letters, digits and _, not starting with a digit',
177 savedAs: (name: string, where: string) => `${name} (saved to ${where})`,
178 },
179 ru: {
180 title: 'Секрет',
181 asks: 'Claude просит ключ',
182 of: (i: number, n: number) => `${i} из ${n}`,
183 why: 'Зачем',
184 where: 'Куда',
185 envProject: '.env в папке проекта',
186 ghTo: (repo: string | undefined, env: string | undefined) =>
187 `GitHub Secrets · ${repo ?? 'этот репозиторий'}${env ? ` (окружение ${env})` : ''}`,
188 cue: 'Вставь ключ',
189 placeholder: 'вставь сюда и нажми Enter',
190 value: 'Значение',
191 next: 'дальше',
192 save: 'сохранить',
193 show: 'Показать',
194 hide: 'Скрыть',
195 skip: 'Пропустить',
196 cancel: 'Отмена',
197 close: 'Закрыть',
198 footer: 'Значение не попадёт в чат. Claude узнает только имя и длину.',
199 saving: 'Сохраняю…',
200 done: 'Готово',
201 noInput: 'Здесь нет поля ввода: открой сессию в терминале или в десктопном приложении.',
202 bandWaiting: (names: string) => `Claude ждёт ${names}`,
203 bandOpen: 'Ввести безопасно',
204 status: (names: string) => `🔐 жду ${names}: /secret откроет окно`,
205 toastNotPlaced: 'secret-drop: расширь окно или набери /secret, чтобы ввести ключ',
206 chars: (n: number) => `${n} симв.`,
207 savedEnv: (names: string, path: string) => `✓ ${names} → ${path}`,
208 savedGh: (name: string, repo: string) => `✓ ${name} → GitHub Secrets (${repo})`,
209 failedEnv: (why: string) => `✗ .env не записан: ${why}`,
210 failedGh: (name: string, why: string) => `✗ ${name} → GitHub: ${why}`,
211 gitignored: (rel: string) => `✓ добавил ${rel} в .gitignore`,
212 tracked: (rel: string) => `⚠ ${rel} уже в git: выполни git rm --cached ${rel}`,
213 nothing: 'Ничего не введено.',
214 leaked: (kinds: string) =>
215 `secret-drop: в сообщении был ключ (${kinds}), я его не отправил. Сохрани ключ в плашке над полем ввода, а сообщение вернулось в поле уже без него.`,
216 guardTitle: 'Поймал ключ в сообщении',
217 guardHint: (kind: string) => `ключ ${kind} уже подставлен: проверь имя и нажми Enter`,
218 nameCue: 'Имя',
219 keyCue: 'Ключ',
220 dontSave: 'Не сохранять',
221 badName: 'secret-drop: имя — латинские буквы, цифры и _, не с цифры',
222 savedAs: (name: string, where: string) => `${name} (сохранён в ${where})`,
223 },
224} satisfies Record<Lang, Record<string, unknown>>
225
226export type Strings = (typeof STRINGS)['en']
227hooks/look.ts 228 lines1// The desktop's drawings: SVG markup built from plain data, so the tests can read it.
2// Native Input and Buttons sit between these pictures; nothing here takes input.
3
4export const ACCENT = '#D97757' // Claude's clay
5const ACCENT_LIGHT = '#EBA488'
6const GREEN = '#2E9E57'
7const AMBER = '#C27C0E'
8const RED = '#D2484F'
9
10const SANS = "'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif"
11const MONO = "'Anthropic Mono',ui-monospace,'SF Mono',Menlo,monospace"
12
13// Lucide icons, 24px grid, drawn as strokes
14const ICON = {
15 key: '<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5"/>',
16 shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
17 check: '<path d="M20 6 9 17l-5-5"/>',
18 alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
19 x: '<path d="M18 6 6 18M6 6l12 12"/>',
20 file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
21 github: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>',
22}
23
24const icon = (name: keyof typeof ICON, x: number, y: number, size: number, color: string, width = 2) =>
25 `<g transform="translate(${x} ${y}) scale(${size / 24})" fill="none" stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round">${ICON[name]}</g>`
26
27export const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
28
29/** A rough width in CSS pixels, enough to fit and truncate text. */
30export const textWidth = (s: string, size = 12, mono = false, tracking = 0) =>
31 [...s].reduce((w, ch) => {
32 if (mono) return w + size * 0.62
33 const k = /[ilI.,:;'|!·]/.test(ch)
34 ? 0.3
35 : ch === ' '
36 ? 0.28
37 : /[mwMWШЩЖМЮшщжмю]/.test(ch)
38 ? 0.88
39 : /[А-ЯЁ]/.test(ch)
40 ? 0.72
41 : /[A-Z]/.test(ch)
42 ? 0.68
43 : /[0-9]/.test(ch)
44 ? 0.6
45 : /[а-яё]/.test(ch)
46 ? 0.6
47 : 0.55
48 return w + size * (k + tracking)
49 }, 0)
50
51export const fit = (s: string, room: number, size = 12, mono = false) => {
52 if (textWidth(s, size, mono) <= room) return s
53 let cut = s
54 while (cut.length > 1 && textWidth(`${cut}…`, size, mono) > room) cut = cut.slice(0, -1)
55
56 return room < size ? '' : `${cut.trimEnd()}…`
57}
58
59const hash = (a: number, b: number, c: number) => {
60 const x = Math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453
61 return x - Math.floor(x)
62}
63
64/** A field of twinkling pixels that fades to the right, as plan-progress draws its tracks. */
65function pixels(width: number, height: number, color: string, seed: number) {
66 const rows = Math.floor((height - 4) / 3)
67 const groups = new Map<number, string>()
68 for (let col = 0; col * 3 < width; col++) {
69 const u = (col * 3) / width
70 const dense = 0.6 * Math.pow(1 - u, 3)
71 for (let r = 0; r < rows; r++) {
72 if (hash(col + seed, r, 1) > dense) continue
73 const t = Math.floor(hash(col, r + seed, 2) * 4)
74 groups.set(t, `${groups.get(t) ?? ''}M${col * 3} ${2 + r * 3}h2v2h-2z`)
75 }
76 }
77
78 return [...groups]
79 .map(([t, d]) => `<path class="px t${t}" d="${d}" fill="${color}"/>`)
80 .join('')
81}
82
83const STYLE = `<style>
84svg{color-scheme:light dark}
85.ink{fill:#F5F4EE}.mute{fill:#A6A39A}.faint{fill:#8A877F}
86.lbl{font:600 10.5px ${SANS};letter-spacing:.07em}
87.why{font:400 11.5px ${SANS}}
88.name{font:600 14px ${MONO}}
89.hint{font:400 12px ${SANS}}
90.chip{font:500 11.5px ${SANS}}
91.foot{font:400 11.5px ${SANS}}
92.row{font:500 12.5px ${SANS}}
93.px{opacity:.4}
94.t0,.t1,.t2,.t3{animation:tw 2.4s ease-in-out infinite}
95.t1{animation-duration:3s;animation-delay:-.8s}.t2{animation-duration:2s;animation-delay:-1.4s}.t3{animation-duration:3.4s;animation-delay:-.5s}
96@keyframes tw{0%,100%{opacity:.4}50%{opacity:.1}}
97@media (prefers-color-scheme:light){.ink{fill:#1F1E1D}.mute{fill:#6B6862}.faint{fill:#8A877F}}
98@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{animation:none}}
99</style>`
100
101const open = (w: number, h: number) =>
102 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${STYLE}`
103
104export type Destination = { kind: 'env' | 'github'; label: string }
105
106export type AskLook = {
107 title: string
108 reason?: string
109 name: string
110 hint?: string
111 step?: { index: number; count: number }
112 destinations: Destination[]
113}
114
115export const HEADER_H = 44
116
117/** A quiet line above the field: a small badge, what is asked and why, where it goes. */
118export function headerSvg(a: AskLook, W: number): string {
119 const H = HEADER_H
120 let right = W - 2
121 let chipMarkup = ''
122 for (const d of [...a.destinations].reverse()) {
123 const full = Math.ceil(textWidth(d.label, 11.5) * 1.1 + 40)
124 const room = Math.min(full, Math.max(90, W * 0.36))
125 const x = right - room
126 chipMarkup +=
127 `<rect x="${x}" y="${(H - 24) / 2}" width="${room}" height="24" rx="12" fill="#808080" fill-opacity=".12"/>` +
128 icon(d.kind === 'github' ? 'github' : 'file', x + 10, (H - 24) / 2 + 5, 14, '#8A877F', 2) +
129 `<text x="${x + 29}" y="${H / 2 + 4}" class="chip mute">${esc(fit(d.label, room - 38, 11.5))}</text>`
130 right = x - 8
131 }
132
133 let dots = ''
134 if (a.step && a.step.count > 1) {
135 const n = a.step.count
136 const label = `${a.step.index + 1}/${n}`
137 const x0 = right - n * 10 - textWidth(label, 11.5) - 8
138 for (let i = 0; i < n; i++) {
139 const isNow = i === a.step.index
140 dots += `<circle cx="${x0 + i * 10 + 3}" cy="${H / 2}" r="3" fill="${isNow ? ACCENT : '#808080'}" fill-opacity="${isNow ? 1 : i < a.step.index ? 0.7 : 0.3}"/>`
141 }
142 dots += `<text x="${x0 + n * 10 + 4}" y="${H / 2 + 4}" class="chip faint">${label}</text>`
143 right = x0 - 12
144 }
145
146 const textX = 46
147 const room = Math.max(40, right - textX - 8)
148 const nameW = textWidth(a.name, 14, true)
149 const name = fit(a.name, room, 14, true)
150 const hint = a.hint ? fit(a.hint, room - nameW - 14, 12) : ''
151 const top = [a.title, a.reason].filter(Boolean).join(' · ')
152
153 return (
154 open(W, H) +
155 `<circle cx="17" cy="${H / 2}" r="15" fill="${ACCENT}" fill-opacity=".14"/>` +
156 icon('key', 9, H / 2 - 8, 16, ACCENT, 2.2) +
157 `<text x="${textX}" y="17" class="why mute">${esc(fit(top, room, 11.5))}</text>` +
158 `<text x="${textX}" y="36" class="name ink">${esc(name)}</text>` +
159 (hint ? `<text x="${textX + nameW + 10}" y="35.5" class="hint mute">${esc(hint)}</text>` : '') +
160 dots +
161 chipMarkup +
162 '</svg>'
163 )
164}
165
166export const CUE_H = 30
167
168export const cueWidth = (text: string) => Math.ceil(textWidth(text, 13) + 30)
169
170/** The pointer at the field: the one thing in the band that asks for attention. */
171export function cueSvg(text: string, width?: number): string {
172 const W = Math.max(width ?? 0, cueWidth(text))
173 const y = CUE_H / 2
174 return (
175 open(W, CUE_H) +
176 `<style>.cue{font:600 13px ${SANS};fill:${ACCENT}}.nudge{animation:nd 1.6s ease-in-out infinite}@keyframes nd{0%,100%{transform:translateX(0)}50%{transform:translateX(3px)}}@media (prefers-reduced-motion:reduce){.nudge{animation:none}}</style>` +
177 `<text x="0" y="${y + 4.5}" class="cue">${esc(text)}</text>` +
178 `<g class="nudge"><path d="M${W - 22} ${y}h12m-4.5-4.5L${W - 10} ${y}l-4.5 4.5" fill="none" stroke="${ACCENT}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></g>` +
179 '</svg>'
180 )
181}
182
183export const FOOT_H = 18
184
185/** The promise under the field. */
186export function footSvg(text: string, W: number): string {
187 return (
188 open(W, FOOT_H) +
189 icon('shield', 2, 2, 14, GREEN, 2) +
190 `<text x="22" y="13" class="foot mute">${esc(fit(text, W - 24, 11.5))}</text></svg>`
191 )
192}
193
194export type ReportLine = { text: string; tone: 'ok' | 'warn' | 'error' }
195
196export const toneOf = (line: string): ReportLine['tone'] =>
197 line.startsWith('✗') ? 'error' : line.startsWith('⚠') ? 'warn' : 'ok'
198
199/** The card after saving: a badge, a title, one line per thing done. */
200export function reportSvg(title: string, lines: ReportLine[], W: number, isBusy: boolean): string {
201 const rowH = 22
202 const H = 52 + Math.max(0, lines.length - 1) * rowH + (lines.length > 0 ? 8 : 0)
203 const hasError = lines.some(l => l.tone === 'error')
204 const color = isBusy ? ACCENT : hasError ? RED : GREEN
205 const rows = lines
206 .map((line, i) => {
207 const y = 22 + i * rowH
208 const c = line.tone === 'error' ? RED : line.tone === 'warn' ? AMBER : GREEN
209 const name = line.tone === 'error' ? 'x' : line.tone === 'warn' ? 'alert' : 'check'
210 const text = line.text.replace(/^[✓✗⚠]\s*/, '')
211 return icon(name, 62, y - 2, 14, c, 2.4) + `<text x="84" y="${y + 9.5}" class="row ink">${esc(fit(text, W - 100, 12.5))}</text>`
212 })
213 .join('')
214
215 return (
216 open(W, H) +
217 `<defs><clipPath id="sd-report-card"><rect width="${W}" height="${H}" rx="14"/></clipPath></defs>` +
218 `<g clip-path="url(#sd-report-card)"><rect width="${W}" height="${H}" fill="${color}" fill-opacity=".10"/>${pixels(Math.min(W * 0.35, 260), H, color, 3)}</g>` +
219 `<rect x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="13.5" fill="none" stroke="${color}" stroke-opacity=".4"/>` +
220 `<circle cx="32" cy="26" r="17" fill="${color}"/>` +
221 (isBusy
222 ? `<circle cx="32" cy="26" r="7" fill="none" stroke="#fff" stroke-width="2.4" stroke-dasharray="30 14" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" from="0 32 26" to="360 32 26" dur="1s" repeatCount="indefinite"/></circle>`
223 : icon(hasError ? 'x' : 'check', 23, 17, 18, '#FFFFFF', 2.6)) +
224 (lines.length === 0 ? `<text x="62" y="31" class="row ink">${esc(title)}</text>` : rows) +
225 '</svg>'
226 )
227}
228types/index.d.ts 38 lines1export type SecretAsk = {
2 name: string
3 hint?: string
4 /** What stands for this key in a blocked prompt, to swap for its name once saved. */
5 placeholder?: string
6}
7
8export type SecretRequest = {
9 id: string
10 secrets: SecretAsk[]
11 reason?: string
12 /** Absolute path of the env file to write, when saving to one. */
13 envFile?: string
14 /** The env file as the window names it: relative to the project, or with ~. */
15 envLabel?: string
16 /** Set when saving to GitHub Actions secrets. */
17 github?: { repo?: string; environment?: string }
18 /** Who started it: the model's tool call, the person's /secret, or the prompt guard. */
19 from: 'model' | 'person' | 'guard'
20 /** The blocked prompt with its keys cut out, when the guard started it. */
21 draft?: string
22 index: number
23 captured: string[]
24 skipped: string[]
25 phase: 'input' | 'saving' | 'finished'
26 report: string[]
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'secret-drop': {
32 request: SecretRequest | null
33 rev: number
34 isRevealed: boolean
35 }
36 }
37}
38