SLOPSHOPPER

secret-env

Type secret keys into a Claude Code pane; they go to .env files and Claude only ever sees [hidden].

newpaneguardcommandtoolprocess
★ 3v0.1.0MITupdated 2026-10-22toy-crane/skills/mods/secret-env
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-env
│ ┃ 비밀 키 ✕ › fix the failing auth test and add an audit log call │ ┃ 이 프로젝트에서 관리하는 키입니다. 값은 │ ┃ 여기서도 보여 주지 않습니다. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 아직 저장한 키가 없습니다. 새 키를 ⏺ Update(src/auth.ts) │ ┃ 추가하거나, Claude가 요청할 때 입력하면 ⎿ 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-env │ ⎿ secret-env: 비밀 키 패널을 열었습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 비밀 키
이 프로젝트에서 관리하는 키입니다. 값은 여기서도 보여 주지 않습니다. 아직 저장한 키가 없습니다. 새 키를 추가하거나, Claude가 요청할 때 입력하면 여기에 나타납니다. [ 새 키 추가 ]
README

secret-env

A Claude Code mod for secret keys Claude should use but never see.

When Claude needs an API key, it asks for it by name. A pane opens beside the chat with the key's name, what it is for, and the .env file it goes to. You type the value there and save. The value is written to that file, so your app reads it the usual way, whether Claude started the app or you did. Claude only learns that the key was saved.

From then on, every tool result Claude reads, from Bash, Read, Grep, or a subagent, shows [hidden] wherever the value appears. That still holds in later sessions: the mod remembers each key by name and file, never by value, and reads the value back from the file when a session starts.

Install

/plugin install secret-env@toycrane

Run it in a terminal session. Add the marketplace first with /plugin marketplace add toy-crane/skills if you have not.

Use

  • Claude calls request_secret with a name, a purpose, and a .env file. Save or cancel in the pane; closing the pane or interrupting Claude cancels too.
  • /secret-env opens the list of keys this project manages. Add a key, or delete one, which removes its line from the file.
  • When the file is not covered by .gitignore, the pane warns you and offers to add it before saving. A name that already exists is replaced only after you confirm. Values shorter than four characters are refused, since they could not be hidden without blanking ordinary output.
  • Claude cannot change a managed key's line with Write or Edit. It can still edit the other lines of the same file.

What it does not protect

  • The value sits in the .env file as plain text. With the mod off or failed to load, Claude reads it like any other file.
  • Only the exact value is hidden. A command that encodes it or prints part of it gets past the filter, and Claude can still run commands that use the key.
  • The text field does not mask what you type; mind screen sharing.
  • The phone app has no text field, so enter keys from the desktop app or a terminal.

Develop

claude plugin test mods/secret-env
claude plugin validate mods/secret-env --strict
claude --plugin-dir mods/secret-env
Source 5 files
hooks/register.tsx 496 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, RenderChildren } from 'claude-code'
3
4import type { Form, ManagedKey, Saved } from '../types'
5import { encodeValue, readValue, removeKey, upsertKey } from './dotenv'
6import { normalizePath } from './paths'
7import { MIN_LENGTH, scrub } from './scrub'
8
9const PANE = 'secret-env'
10const TOOL = 'request_secret'
11// Narrow enough to leave the chat readable beside the docked pane.
12const PANE_COLUMNS = 44
13const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
14const ENV_FILE = /^(?!\/)(?!.*(^|\/)\.\.(\/|$))(.*\/)?\.env[^/]*$/
15
16const formAtom = atom({ plugin: 'secret-env', key: 'form' } as const, null as Form | null)
17const savedAtom = atom({ plugin: 'secret-env', key: 'saved' } as const, null as Saved | null)
18const keysAtom = atom({ plugin: 'secret-env', key: 'keys' } as const, [] as ManagedKey[])
19const deletingAtom = atom({ plugin: 'secret-env', key: 'deleting' } as const, null as string | null)
20const noticeAtom = atom({ plugin: 'secret-env', key: 'notice' } as const, null as string | null)
21
22let root = ''
23let keys: ManagedKey[] = []
24let secrets: string[] = []
25// Managed files that exist but could not be read; while any remain, results are withheld.
26let unreadable: string[] = []
27let isWindows = false
28// What is typed in the pane lives only in these, never in session state.
29let draft = ''
30let draftName = ''
31let draftFile = '.env'
32let outcome: 'saved' | 'cancelled' | undefined
33let isWaiting = false
34
35// Read through a call so the wait loop's narrowing does not stick to the variable.
36const outcomeNow = () => outcome
37
38const keyId = (key: { file: string; name: string }) => `${key.file}:${key.name}`
39
40async function readText($: Engine, path: string): Promise<string | undefined> {
41  return $.fs.read(path).catch(() => undefined)
42}
43
44async function loadSecrets($: Engine) {
45  const values: string[] = []
46  const failed = new Set<string>()
47  for (const key of keys) {
48    const path = `${root}/${key.file}`
49    let text: string
50    try {
51      if (!(await $.fs.exists(path))) continue
52      text = await $.fs.read(path)
53    } catch {
54      failed.add(key.file)
55      continue
56    }
57    const value = readValue(text, key.name)
58    // The file may spell the value quoted and escaped; hide that spelling as well.
59    if (value !== undefined) values.push(value, encodeValue(value))
60  }
61  secrets = values
62  unreadable = [...failed]
63}
64
65// Memory and hiding change first, so a failed store write never leaves a written value unhidden.
66async function setKeys($: Engine, next: ManagedKey[]) {
67  keys = next
68  await loadSecrets($)
69  await update($, keysAtom, () => keys)
70  await $.store.set(`keys:${root}`, keys)
71}
72
73// Whether git would commit the file (exit 1 from check-ignore), and whether the name is already set.
74async function conflictsOf($: Engine, name: string, file: string) {
75  const ignored = await isIgnored($, file)
76  // A tracked file stays committed whatever .gitignore says.
77  const listed = await $.process
78    .run(['git', 'ls-files', '--error-unmatch', '--', file], { cwd: root })
79    .catch(() => undefined)
80  const text = await readText($, `${root}/${file}`)
81  return {
82    isTracked: listed?.exitCode === 0,
83    isUnignored: ignored === false,
84    exists: text !== undefined && readValue(text, name) !== undefined,
85  }
86}
87
88// The file's text, '' when it does not exist yet; a file that exists but cannot be read throws.
89async function readForEdit($: Engine, path: string): Promise<string> {
90  return (await $.fs.exists(path)) ? $.fs.read(path) : ''
91}
92
93async function isIgnored($: Engine, file: string): Promise<boolean | undefined> {
94  const check = await $.process
95    .run(['git', 'check-ignore', '-q', file], { cwd: root })
96    .catch(() => undefined)
97  return check === undefined || check.exitCode > 1 ? undefined : check.exitCode === 0
98}
99
100async function addToGitignore($: Engine, file: string) {
101  const path = `${root}/.gitignore`
102  const text = await readForEdit($, path)
103  const head = text === '' || text.endsWith('\n') ? text : `${text}\n`
104  // A leading # or ! would make the line a comment or a negation.
105  await $.fs.write(path, `${head}${file.replace(/^([#!])/, '\\$1')}\n`)
106  if ((await isIgnored($, file)) === false) {
107    throw new Error(`.gitignore에 ${file}을 추가했지만 git이 여전히 이 파일을 무시하지 않습니다.`)
108  }
109}
110
111async function writeKey($: Engine, form: Form, value: string) {
112  if (form.isUnignored) await addToGitignore($, form.file)
113  const path = `${root}/${form.file}`
114  await $.fs.write(path, upsertKey(await readForEdit($, path), form.name, value))
115  await setKeys($, [
116    ...keys.filter(key => keyId(key) !== keyId(form)),
117    { name: form.name, file: form.file, savedAt: await $.clock.now() },
118  ])
119}
120
121const reasonOf = (err: unknown) => (err instanceof Error ? err.message : String(err))
122
123// Removes the key's line, and stops managing the key only once the file no longer holds it.
124async function deleteKey($: Engine, id: string): Promise<string | undefined> {
125  const key = keys.find(one => keyId(one) === id)
126  if (key === undefined) return undefined
127  const path = `${root}/${key.file}`
128  if (await $.fs.exists(path)) {
129    try {
130      await $.fs.write(path, removeKey(await $.fs.read(path), key.name))
131      if (readValue(await $.fs.read(path), key.name) !== undefined) throw new Error('the line is still there')
132    } catch (err) {
133      return `${key.file}에서 ${key.name}를 지우지 못했습니다. ${reasonOf(err)}`
134    }
135  }
136  await setKeys($, keys.filter(one => one !== key))
137  return undefined
138}
139
140// One spelling per file: links and . or .. resolved, so another spelling cannot slip past a guard.
141async function canonical($: Engine, path: string): Promise<string> {
142  const absolute = normalizePath(path, root)
143  const stat = await $.fs.stat(absolute, { resolve: true }).catch(() => undefined)
144  return stat?.realPath === undefined ? absolute : normalizePath(stat.realPath, root)
145}
146
147// The managed key names kept in the file at `path`, absolute or relative to the project.
148async function managedIn($: Engine, path: string): Promise<string[]> {
149  const target = await canonical($, path)
150  const names: string[] = []
151  for (const key of keys) {
152    if ((await canonical($, `${root}/${key.file}`)) === target) names.push(key.name)
153  }
154  return names
155}
156
157function trackedMessage(file: string): string {
158  return `${file}은 git이 이미 추적하는 파일이라 저장할 수 없습니다. git rm --cached ${file}로 추적을 멈춘 뒤 다시 시도해 주세요.`
159}
160
161function saveLabel(form: Form): string {
162  if (form.isUnignored) return form.exists ? '.gitignore에 추가하고 바꾸기' : '.gitignore에 추가하고 저장'
163  return form.exists ? '새 값으로 바꾸기' : '저장'
164}
165
166function savedWhen(ms: number): string {
167  const at = new Date(ms)
168  return `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')}`
169}
170
171function refusal(name: string, file: string): string | undefined {
172  if (!NAME.test(name)) return `${name} is not a valid environment variable name.`
173  if (file.includes('\\') || /^[A-Za-z]:/.test(file) || !ENV_FILE.test(file)) return `${file} must be a .env file inside the project, such as .env or .env.local.`
174  return undefined
175}
176
177export const register: Register = on => {
178  on('session.start', async ($, e, next) => {
179    root = await $.session.root()
180    isWindows = (await $.env.get('OS')) === 'Windows_NT'
181    keys = ((await $.store.get(`keys:${root}`)) as ManagedKey[] | undefined) ?? []
182    await update($, keysAtom, () => keys)
183    await loadSecrets($)
184    await $.command.register({
185      name: 'secret-env',
186      description: 'List, add, or delete the secret keys Claude cannot see',
187    })
188    await $.tool.register({
189      name: TOOL,
190      description:
191        'Ask the user to type a secret (API key, token) into a pane beside the chat. The value is written to the given .env file and never shown to you; refer to it by its variable name. Waits until the user saves or cancels.',
192      inputSchema: {
193        type: 'object',
194        properties: {
195          name: { type: 'string', description: 'Environment variable name, e.g. STRIPE_SECRET_KEY' },
196          purpose: { type: 'string', description: 'What the key is for, shown to the user' },
197          file: {
198            type: 'string',
199            description: 'The .env file relative to the project root that the app reads, e.g. .env.local',
200          },
201        },
202        required: ['name', 'purpose', 'file'],
203      },
204      isDeferred: false,
205    })
206    return next(e)
207  })
208
209  on('command.run', { command: 'secret-env' }, async $ => {
210    await update($, savedAtom, () => null)
211    await update($, deletingAtom, () => null)
212    await update($, noticeAtom, () => null)
213    if (!isWaiting) await update($, formAtom, () => null)
214    await $.ui.open({ id: PANE, title: '비밀 키', focus: true, columns: PANE_COLUMNS })
215    return { text: '비밀 키 패널을 열었습니다.' }
216  })
217
218  on('tool.call', { tool: 'mcp__secret-env__request_secret' }, async ($, e, next) => {
219    const input = e as unknown as { name: string; purpose: string; file: string }
220    const refused = refusal(input.name, input.file)
221    if (refused !== undefined) return { result: refused }
222    if (isWaiting) return { result: 'Another key request is still open in the pane; wait for it first.' }
223
224    isWaiting = true
225    draft = ''
226    outcome = undefined
227    try {
228      const conflicts = await conflictsOf($, input.name, input.file)
229      await update($, savedAtom, () => null)
230      const asked: Form = { source: 'claude', name: input.name, file: input.file, purpose: input.purpose, ...conflicts }
231      await update($, formAtom, () => asked)
232      await $.ui.open({ id: PANE, title: '비밀 키 입력', focus: true, columns: PANE_COLUMNS })
233      // Each sleep is a $ call, so the wait does not spend the hook's own time budget.
234      while (outcome === undefined && !next.signal.aborted) {
235        await $.process.run(
236          isWindows ? ['powershell', '-NoProfile', '-Command', 'Start-Sleep -Milliseconds 300'] : ['/bin/sleep', '0.3'],
237        )
238      }
239    } finally {
240      isWaiting = false
241      draft = ''
242      if (outcome === undefined) {
243        outcome = 'cancelled'
244        await update($, formAtom, () => null)
245        await $.ui.close({ id: PANE })
246      }
247    }
248    const ended = outcomeNow()
249    return {
250      result:
251        ended === 'saved'
252          ? `The user saved ${input.name} to ${input.file}. Its value is hidden from you; use it by name.`
253          : `The user cancelled entering ${input.name}.`,
254    }
255  })
256
257  // Claude never knows a managed value, so any rewrite of its line would destroy it.
258  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
259    const guarded = await managedIn($, e.file_path)
260    return guarded.length > 0
261      ? { deny: `${e.file_path} holds ${guarded.join(', ')}, which only the user can change. Use Edit on other lines.` }
262      : next(e)
263  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-env could not check this file.' }))
264
265  // Judged on the file as the edit would leave it, so renaming or reshaping a managed line is caught too.
266  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
267    const names = await managedIn($, e.file_path)
268    if (names.length === 0) return next(e)
269    const before = await $.fs.read(normalizePath(e.file_path, root))
270    // An old_string the file does not hold (Claude saw [hidden]) is judged by the names it mentions.
271    const touched = before.includes(e.old_string)
272      ? names.filter(name => {
273          const after = e.replace_all
274            ? before.split(e.old_string).join(e.new_string)
275            : before.replace(e.old_string, () => e.new_string)
276          return readValue(before, name) !== readValue(after, name)
277        })
278      : names.filter(name => e.old_string.includes(name) || e.new_string.includes(name))
279    return touched.length > 0
280      ? { deny: `${touched.join(', ')} can only be changed by the user. Ask them to run /secret-env.` }
281      : next(e)
282  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-env could not check this edit.' }))
283
284  // Every tool result, from the main loop or a subagent, passes here on its way to the model.
285  on('tool.call', async ($, e, next) => {
286    const ran = await next(e)
287    if (ran.deny !== undefined) return ran
288    if (unreadable.length > 0) await loadSecrets($)
289    if (unreadable.length > 0) {
290      return { deny: `secret-env could not read ${unreadable.join(', ')} to hide its keys, so this result was withheld.` }
291    }
292    return { ...ran, result: scrub(ran.result, secrets), text: scrub(ran.text, secrets) }
293  }).catch(($, e, next) =>
294    // Fail closed: a result that could not be scrubbed is withheld, never passed on.
295    next.called ? { deny: 'secret-env could not hide secrets in this result, so it was withheld.' } : next(e),
296  )
297
298  on('ui.close', { id: PANE }, async ($, e, next) => {
299    if (isWaiting && outcome === undefined) {
300      outcome = 'cancelled'
301      await update($, formAtom, () => null)
302    }
303    return next(e)
304  })
305
306  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307    const elements = $.ui.resolve(e)
308    const { Box, Text, Button } = elements
309    // The phone surface has no text field, so keys are entered elsewhere.
310    const Input = e.surface !== 'mobile' && 'Input' in elements ? elements.Input : undefined
311    const form = await read($, formAtom)
312    const saved = await read($, savedAtom)
313
314    if (form !== null && Input === undefined) {
315      return (
316        <Text>
317          {form.name} 입력은 이 화면에서 할 수 없습니다. 데스크톱 앱이나 터미널에서 이 패널을 열어 주세요.
318        </Text>
319      )
320    }
321
322    if (form !== null && Input !== undefined) {
323      const save = async () => {
324        const current = { ...form, ...(form.source === 'user' ? { name: draftName.trim(), file: draftFile.trim() } : {}) }
325        const missing =
326          current.name === ''
327            ? '키 이름을 입력해 주세요.'
328            : draft === ''
329              ? '값을 입력해 주세요.'
330              : draft.length < MIN_LENGTH
331                ? `값은 ${MIN_LENGTH}자 이상이어야 가릴 수 있습니다.`
332                : undefined
333        const invalid = missing ?? (refusal(current.name, current.file) && '키 이름이나 저장 위치가 올바르지 않습니다.')
334        if (invalid) {
335          await update($, formAtom, () => ({ ...form, error: invalid }))
336          return
337        }
338        const target = keyId(current)
339        if (current.source === 'user' && current.checked !== target) {
340          const conflicts = await conflictsOf($, current.name, current.file)
341          if (conflicts.isTracked || conflicts.isUnignored || conflicts.exists) {
342            await update($, formAtom, () => ({ ...current, ...conflicts, checked: target, error: undefined }))
343            return
344          }
345          Object.assign(current, conflicts)
346        }
347        if (current.isTracked) {
348          await update($, formAtom, () => ({ ...current, error: trackedMessage(current.file) }))
349          return
350        }
351        try {
352          await writeKey($, current, draft)
353        } catch (err) {
354          await update($, formAtom, () => ({ ...form, error: `${current.file}에 저장하지 못했습니다. ${reasonOf(err)}` }))
355          return
356        }
357        draft = ''
358        await update($, formAtom, () => null)
359        if (current.source === 'claude') {
360          outcome = 'saved'
361          await update($, savedAtom, () => ({ name: current.name, file: current.file }))
362        }
363      }
364
365      const cancel = async () => {
366        draft = ''
367        await update($, formAtom, () => null)
368        if (form.source === 'claude') {
369          outcome = 'cancelled'
370          await $.ui.close({ id: PANE })
371        }
372      }
373
374      // Each label sits above its value, so fields line up whatever the label's length.
375      const field = (label: string, body: RenderChildren) => (
376        <Box flexDirection="column" gap="xs" width="100%">
377          <Text dimColor size="footnote">
378            {label}
379          </Text>
380          {body}
381        </Box>
382      )
383
384      return (
385        <Box flexDirection="column" gap="md" width="100%">
386          {form.source === 'claude' ? (
387            <Box flexDirection="column" gap="md" width="100%">
388              {field('키 이름', <Text mono>{form.name}</Text>)}
389              {form.purpose !== undefined && field('용도', <Text>{form.purpose}</Text>)}
390              {field('저장 위치', <Text mono>{form.file}</Text>)}
391            </Box>
392          ) : (
393            <Box flexDirection="column" gap="md" width="100%">
394              {field(
395                '키 이름',
396                <Input key="name" placeholder="OPENAI_API_KEY" value={draftName} onInput={(v: string) => (draftName = v)} onSubmit={save} />,
397              )}
398              {field(
399                '저장 위치',
400                <Input key="file" value={draftFile} onInput={(v: string) => (draftFile = v)} onSubmit={save} />,
401              )}
402            </Box>
403          )}
404          {field(
405            '값',
406            <Input key="value" autoFocus value={draft} onInput={(v: string) => (draft = v)} onSubmit={save} />,
407          )}
408          <Text dimColor size="footnote">
409            Claude에게는 전달되지 않습니다.
410          </Text>
411          {form.isTracked && <Text color="error">{trackedMessage(form.file)}</Text>}
412          {!form.isTracked && form.isUnignored && (
413            <Text color="warning">{form.file}이 .gitignore에 없습니다. 이대로 저장하면 키가 커밋될 수 있습니다.</Text>
414          )}
415          {form.exists && (
416            <Text color="warning">
417              {form.file}에 {form.name}가 이미 있습니다. 저장하면 새 값으로 바뀝니다.
418            </Text>
419          )}
420          {form.error !== undefined && <Text color="error">{form.error}</Text>}
421          <Box flexDirection="row" gap={1}>
422            <Button key="save" label={saveLabel(form)} variant="primary" onPress={save} />
423            <Button key="cancel" label="취소" onPress={cancel} />
424          </Box>
425        </Box>
426      )
427    }
428
429    if (saved !== null) {
430      return (
431        <Box flexDirection="column" gap={1}>
432          <Text>{saved.name}를 {saved.file}에 저장했습니다.</Text>
433          <Text dimColor>Claude가 보는 모든 도구 결과에서 이 값은 [hidden]으로 바뀝니다.</Text>
434          <Button key="close" label="닫기" onPress={() => $.ui.close({ id: PANE })} />
435        </Box>
436      )
437    }
438
439    const list = await read($, keysAtom)
440    const deleting = await read($, deletingAtom)
441    const notice = await read($, noticeAtom)
442    const startAdding = async () => {
443      draft = ''
444      draftName = ''
445      draftFile = '.env'
446      await update($, formAtom, () => ({ source: 'user', name: '', file: '.env', isTracked: false, isUnignored: false, exists: false }))
447    }
448
449    return (
450      <Box flexDirection="column" gap={1} width="100%">
451        <Text dimColor size="footnote">
452          이 프로젝트에서 관리하는 키입니다. 값은 여기서도 보여 주지 않습니다.
453        </Text>
454        {notice !== null && <Text color="error">{notice}</Text>}
455        {list.length === 0 && (
456          <Text dimColor>아직 저장한 키가 없습니다. 새 키를 추가하거나, Claude가 요청할 때 입력하면 여기에 나타납니다.</Text>
457        )}
458        {list.map(key => (
459          <Box flexDirection="column" width="100%">
460            <Box flexDirection="row" gap={1} alignItems="center" justifyContent="space-between" width="100%">
461              <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
462                <Text bold mono wrap="truncate-end">
463                  {key.name}
464                </Text>
465                <Text dimColor size="footnote" wrap="truncate-end">
466                  {key.file} · {savedWhen(key.savedAt)}
467                </Text>
468              </Box>
469              <Box flexShrink={0}>
470                <Button key={`delete:${keyId(key)}`} label="삭제" onPress={() => update($, deletingAtom, () => keyId(key))} />
471              </Box>
472            </Box>
473            {deleting === keyId(key) && (
474              <Box flexDirection="row" gap={1}>
475                <Text>{key.file}에서 지웁니다.</Text>
476                <Button
477                  key="confirm-delete"
478                  label="지우기"
479                  variant="primary"
480                  onPress={async () => {
481                    const failed = await deleteKey($, keyId(key))
482                    await update($, noticeAtom, () => failed ?? null)
483                    await update($, deletingAtom, () => null)
484                  }}
485                />
486                <Button key="cancel-delete" label="취소" onPress={() => update($, deletingAtom, () => null)} />
487              </Box>
488            )}
489          </Box>
490        ))}
491        <Button key="add" label="새 키 추가" variant="primary" onPress={startAdding} />
492      </Box>
493    )
494  })
495}
496
hooks/dotenv.ts 69 lines
1// Line-level edits of .env files that leave every other line as it was.
2
3const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/
4const BARE = /^[A-Za-z0-9_\-.\/+:@,=~]*$/
5
6function keyOf(line: string): string | undefined {
7  return KEY_LINE.exec(line)?.[1]
8}
9
10// How a value is spelled on its line: bare when it can be, else double-quoted.
11export function encodeValue(value: string): string {
12  if (BARE.test(value)) return value
13  // $ and backticks are escaped too, so sourcing the file keeps the value literal.
14  return `"${value.replace(/[\\"$`]/g, ch => `\\${ch}`).replace(/\n/g, '\\n')}"`
15}
16
17function decodeValue(raw: string): string {
18  const text = raw.trim()
19  if (text.startsWith('"')) {
20    let out = ''
21    for (let i = 1; i < text.length; i++) {
22      const ch = text[i]
23      if (ch === '"') break
24      if (ch === '\\' && i + 1 < text.length) {
25        const esc = text[++i]
26        out += esc === 'n' ? '\n' : esc
27      } else {
28        out += ch
29      }
30    }
31    return out
32  }
33  if (text.startsWith("'")) {
34    const end = text.indexOf("'", 1)
35    return text.slice(1, end < 0 ? undefined : end)
36  }
37  return text.replace(/\s+#.*$/, '')
38}
39
40export function upsertKey(text: string, name: string, value: string): string {
41  const line = `${name}=${encodeValue(value)}`
42  const lines = text.split('\n')
43  const at = lines.findIndex(one => keyOf(one) === name)
44  if (at >= 0) {
45    // Later duplicates would win when the file is loaded, so only one line remains.
46    lines[at] = line
47    return lines.filter((one, i) => i === at || keyOf(one) !== name).join('\n')
48  }
49  const head = text === '' || text.endsWith('\n') ? text : `${text}\n`
50  return `${head}${line}\n`
51}
52
53export function removeKey(text: string, name: string): string {
54  return text
55    .split('\n')
56    .filter(line => keyOf(line) !== name)
57    .join('\n')
58}
59
60// The last definition wins, as it does when a shell or dotenv loader reads the file.
61export function readValue(text: string, name: string): string | undefined {
62  let value: string | undefined
63  for (const line of text.split('\n')) {
64    const match = KEY_LINE.exec(line)
65    if (match?.[1] === name) value = decodeValue(match[2] ?? '')
66  }
67  return value
68}
69
hooks/paths.ts 19 lines
1// One spelling per path, on POSIX and Windows alike, so guards compare files rather than strings.
2
3const DRIVE = /^([A-Za-z]):/
4
5export function normalizePath(path: string, root: string): string {
6  const slashed = (one: string) => one.replace(/\\/g, '/')
7  const isAbsolute = (one: string) => one.startsWith('/') || DRIVE.test(one)
8  const joined = isAbsolute(slashed(path)) ? slashed(path) : `${slashed(root)}/${slashed(path)}`
9  const drive = DRIVE.exec(joined)?.[1]
10  const parts: string[] = []
11  for (const part of joined.replace(DRIVE, '').split('/')) {
12    if (part === '' || part === '.') continue
13    if (part === '..') parts.pop()
14    else parts.push(part)
15  }
16  // Windows drive letters and paths are case-insensitive.
17  return drive === undefined ? `/${parts.join('/')}` : `${drive.toLowerCase()}:/${parts.join('/')}`.toLowerCase()
18}
19
hooks/scrub.ts 25 lines
1// Replaces every known secret value inside any JSON-shaped value.
2
3export const HIDDEN = '[hidden]'
4export const MIN_LENGTH = 4
5
6export function scrub<T>(value: T, secrets: readonly string[]): T {
7  const needles = secrets
8    .filter(one => one.length >= MIN_LENGTH)
9    .sort((a, b) => b.length - a.length)
10  if (needles.length === 0) return value
11
12  const walk = (node: unknown): unknown => {
13    if (typeof node === 'string') {
14      return needles.reduce((text, needle) => text.split(needle).join(HIDDEN), node)
15    }
16    if (Array.isArray(node)) return node.map(walk)
17    if (node !== null && typeof node === 'object') {
18      return Object.fromEntries(Object.entries(node).map(([key, one]) => [key, walk(one)]))
19    }
20    return node
21  }
22
23  return walk(value) as T
24}
25
types/index.d.ts 31 lines
1export type ManagedKey = { name: string; file: string; savedAt: number }
2
3// The key being entered in the pane. The typed value is never kept here.
4export type Form = {
5  source: 'claude' | 'user'
6  name: string
7  file: string
8  purpose?: string
9  // Git already tracks the file, so a key saved there would be committed.
10  isTracked: boolean
11  isUnignored: boolean
12  exists: boolean
13  // For a key added from the list: the file:name the warnings were shown for, so saving it again writes.
14  checked?: string
15  error?: string
16}
17
18export type Saved = { name: string; file: string }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'secret-env': {
23      form: Form | null
24      saved: Saved | null
25      keys: ManagedKey[]
26      deleting: string | null
27      notice: string | null
28    }
29  }
30}
31