SLOPSHOPPER

secret-drop

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…

newbandguardcommandtoaststatus
★ 1v0.2.1MITupdated 2026-10-10aislam23/secret-drop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-drop
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 › /secret ⎿ secret-drop: Usage: /secret NAME [NAME…] [--github [owner/repo]] [--env-file path] [--no-env] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🔐 secret-drop

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.

Install

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.

How it works

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.

What it does with the value

  • .env: updates the line in place, keeping 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.
  • GitHub: runs 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.
  • The value stays only in the mod's memory until it is saved. It is not written to the transcript, the mod's state, logs or the message to Claude, and the masked field shows dots.

Settings

In /config → secret-drop:

OptionDefault
languageautoen / ru window text. auto follows LANG, falling back to the macOS language list
guardPromptstrueblock messages that contain keys

Limits

  • This keeps secrets out of the conversation. Claude can still run cat .env if you allow it, so say no to that request. The tool's description tells Claude not to.
  • The masked field redraws as you type. Over a slow link a character may show for a split second before it turns into a dot.
  • On the desktop app the text field has a fixed width, so a very long key scrolls inside it.
  • Other mods that hook ui.input could see what you type, just as any installed plugin sees your keystrokes. Install mods you trust.
  • The mobile app has no text field yet. Use the terminal or the desktop app.

Development

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 симв.)».

  • Claude сам вызывает инструмент request_secret, а ты можешь открыть окно сам: /secret ИМЯ [--github].
  • Если всё-таки вставишь ключ в сообщение, оно не уйдёт. Сразу откроется плашка с уже подставленным ключом и угаданным именем (например ELEVENLABS_API_KEY): жмёшь Enter, и ключ сохранён, а в сообщении вместо ключа стоит его имя.
  • .env добавляется в .gitignore. В GitHub значение уходит через stdin gh secret set.

Как мод делался вместе с Claude, со скриншотами и выводами про работу с агентом: Как я научил Claude безопасно брать пароли и ключи с помощью мода.

Source 4 files
hooks/register.tsx 714 lines
1import { 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}
714
hooks/lib.ts 227 lines
1// 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']
227
hooks/look.ts 228 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
228
types/index.d.ts 38 lines
1export 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