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

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.
/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.
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..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..env file as plain text. With the mod off or failed to load, Claude reads it like any other file.claude plugin test mods/secret-env
claude plugin validate mods/secret-env --strict
claude --plugin-dir mods/secret-envhooks/register.tsx 496 lines1import { 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}
496hooks/dotenv.ts 69 lines1// 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}
69hooks/paths.ts 19 lines1// 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}
19hooks/scrub.ts 25 lines1// 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}
25types/index.d.ts 31 lines1export 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