Edit .env files in a pane inside Claude Code. Claude manages keys and asks you for values without ever seeing them.

<img src="assets/logo.png" alt="env" width="160">
<h1 align="center">env</h1>
A Claude Code mod for <code>.env</code> files.<br> Claude knows which keys exist and asks you for values. You paste them into a pane it can't read.
<img src="https://img.shields.io/badge/version-0.4.0-000" alt="Version 0.4.0"> <img src="https://img.shields.io/badge/Claude%20Code-2.1.287%2B-000" alt="Claude Code 2.1.287+"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-000" alt="MIT"></a>
You're wiring up a deploy with Claude, and it needs your Cloudflare account ID. So it tells you to "add it to your .env", and you go find the dashboard, find the field, open the file, paste, save. Or you're in a hurry and paste it straight into the chat, where it now lives in a transcript forever. Either way, Claude can cat .env whenever it likes.
With env, Claude asks with a card instead: what it needs, why, the steps to find it, and a link to the exact page. You paste the value and press Enter. Claude hears back CLOUDFLARE_ACCOUNT_ID is now set in .env and carries on.
The value went into the file. It never went into the conversation.
╭──────────────────────────────────────────────────────╮
│ Claude needs a value │
│ │
│ CLOUDFLARE_ACCOUNT_ID → .env │
│ Wrangler needs it to deploy the worker │
│ │
│ 1. Open the Cloudflare dashboard │
│ 2. Click ⋯ next to your account name │
│ 3. Click Copy account ID │
│ │
│ https://dash.cloudflare.com copy link │
│ Looks like: 32-character hex │
│ │
│ Paste ▸ ________________________________ save │
│ ✓ looks right │
│ skip for now │
╰──────────────────────────────────────────────────────╯
/plugin marketplace add davekiss/cc-plugins
/plugin install env@davekiss
Then ask Claude for something that needs a key, or type /env to open the pane yourself.
When Claude needs a value it calls mcp__env__request, and you get a card with the steps to find it, named by the labels on screen, plus the link to the page it lives on. If Claude knows what a valid value looks like, the card checks your paste as you type: ✓ looks right, or a red warning when you've copied the account name instead of the ID. Enter still saves, since you know better than a regex.
Skip it or close the pane, and Claude is told that too, so it never sits waiting on you.
"Set up the Stripe keys." · "I need a Cloudflare token for Workers AI." · "Fill in whatever .env.example has that I'm missing."
Two layers keep values out of the conversation:
.env* files. Bash commands that read them (cat .env, source .env, printenv) are refused, and the refusal points Claude to env's own tools. Templates like .env.example stay readable.[env:KEY] in every tool result, message and prompt before it's stored or sent. That catches the indirect leaks, like a script that prints process.env. The fence is best-effort; the scrubber is the guarantee.Programs that load .env themselves, like npm run dev or your tests, run normally.
Claude can manage your env files without reading them:
| Tool | What it does |
|---|---|
mcp__env__list | Lists keys and what each value looks like (kind, length, a known prefix like sk_live_), never the value |
mcp__env__request | Shows you the card above |
mcp__env__generate | Writes a random secret (auth secrets, signing keys) without returning it |
mcp__env__copy | Copies a value, or a whole file, to another env file or into another git worktree, without reading it |
mcp__env__remove | Comments a key out, so you can bring it back |
mcp__env__push | Runs a hosting CLI (vercel, gh, wrangler, fly…) with {{KEY}} filled in from your env file, after you approve the command |
mcp__env__pull | Runs a hosting or secrets CLI (vercel env pull, doppler, op read…) and writes what it returns into your env file, reporting only which keys changed |
"Rename CLOUDFLARE_API_TOKEN to CLOUDFLARE_AUTH_TOKEN." · "Copy the database URL into .env.test." · "Copy my .env.local into the new worktree." · "Generate a NEXTAUTH_SECRET."
Claude can send a value to Vercel, GitHub Actions, Cloudflare and the rest through the CLIs you're already logged into. It writes the command with a placeholder, you approve it, and env fills in the value and pipes it to the CLI's stdin:
Run `vercel env add STRIPE_SECRET_KEY production < "{{STRIPE_SECRET_KEY}}"`
with STRIPE_SECRET_KEY filled in from .env.local?
Only known hosting CLIs run (vercel, gh, wrangler, fly, netlify, railway, heroku, supabase, firebase, render, doppler, op, aws, gcloud, az), and any value that comes back in their output is redacted before Claude reads it.
It works the other way too. mcp__env__pull runs vercel env pull, doppler secrets download, heroku config or op read and merges the result into your env file, keeping your comments and order. A key you already set to something else is left alone unless you agree to replace it.
"Push the Stripe keys to Vercel production." · "Set the Cloudflare token as a GitHub Actions secret." · "Pull the dev env from Vercel." · "Get the Stripe key from 1Password."
/env opens a pane with a tab for each .env* file in the project root. Values are masked (sk_live_••••4f2a), and each key has buttons to copy, edit, show or delete it. + add key adds a new one, and keys that .env.example has but your file doesn't are listed so you can fill them in with one click. A warning shows if the file isn't in .gitignore.
Comments, ordering and quoting stay as you wrote them. Only the line you change is rewritten.
A pane that Claude opens on its own needs 144 columns. Below that, the card shows in the band above the prompt instead, and Claude tells you to paste it there. Type /env or press open /env pane whenever you want the full pane.
claude -p, the fence, scrubber and tools still work, but there's no pane.claude plugin validate .
claude plugin test .
claude --plugin-dir "$PWD" # load the mod from this checkout
To release, bump the version in .claude-plugin/plugin.json and the badge above, then push.
MIT
hooks/register.tsx 901 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { EnvEditing, EnvFit, EnvRequest } from '../types'
5import * as env from './dotenv'
6import { basename, bashReason, grepReason, isProtectedPath } from './guard'
7import * as pull from './pull'
8import * as push from './push'
9import * as worktree from './worktree'
10
11const PANE = 'env'
12const TOOL = 'mcp__env__'
13
14const files = atom({ plugin: 'env', key: 'files' } as const, [] as string[])
15const active = atom({ plugin: 'env', key: 'active' } as const, '')
16const editing = atom({ plugin: 'env', key: 'editing' } as const, null as EnvEditing | null)
17const revealed = atom({ plugin: 'env', key: 'revealed' } as const, null as string | null)
18const confirm = atom({ plugin: 'env', key: 'confirm' } as const, null as string | null)
19const request = atom({ plugin: 'env', key: 'request' } as const, null as EnvRequest | null)
20const fit = atom({ plugin: 'env', key: 'fit' } as const, null as EnvFit)
21const rev = atom({ plugin: 'env', key: 'rev' } as const, 0)
22const flash = atom({ plugin: 'env', key: 'flash' } as const, null as string | null)
23// The pane is open but the surface hasn't placed it (opened unasked on a
24// narrow terminal), so the request card draws above the prompt instead
25const isWaiting = atom({ plugin: 'env', key: 'isWaiting' } as const, false)
26
27const KEY_NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/
28
29const DENY =
30 'env: .env files are edited by the user in the /env pane, and their values never enter ' +
31 'this conversation. Use mcp__env__list to see keys and what each value looks like, ' +
32 'mcp__env__request to ask the user for a value, mcp__env__generate for a random secret, and ' +
33 'mcp__env__push and mcp__env__pull to send values to or fetch them from a hosting service through its CLI. ' +
34 'Programs that load .env (npm run dev, tests) still run normally.'
35
36type Engine = EngineInterface
37
38// Value -> key, for the scrubber. Rebuilt whenever an env file changes.
39let index: env.Index = []
40let signature = ''
41
42function rank(name: string): number {
43 if (env.isTemplate(name)) return 9
44 if (name === '.env') return 0
45 if (name === '.env.local') return 1
46 return 2
47}
48
49async function load($: Engine, file: string): Promise<env.Line[]> {
50 try {
51 return env.parse(await $.fs.read(file))
52 } catch {
53 return []
54 }
55}
56
57// Reads every env file in the project root, rebuilds the scrub index and
58// redraws the pane. Cheap enough to run on every change.
59async function refresh($: Engine): Promise<void> {
60 const listing = await $.fs.list().catch(() => [])
61 const names = listing
62 .filter(entry => entry.kind === 'file' && env.isEnvFile(entry.name))
63 .map(entry => entry.name)
64 .sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
65
66 const parsed: Record<string, env.Line[]> = {}
67 for (const name of names) parsed[name] = await load($, name)
68 index = env.buildIndex(parsed)
69 signature = listing
70 .filter(entry => env.isEnvFile(entry.name))
71 .map(entry => `${entry.name}:${entry.mtimeMs}:${entry.size}`)
72 .join('|')
73
74 await update($, files, () => names)
75 await update($, active, current => (names.includes(current) ? current : (names[0] ?? '')))
76 await update($, rev, n => n + 1)
77}
78
79async function save($: Engine, file: string, lines: env.Line[]): Promise<void> {
80 await $.fs.write(file, env.serialize(lines))
81 await refresh($)
82}
83
84async function defaultFile($: Engine): Promise<string> {
85 const names = await read($, files)
86 return names.includes('.env.local') ? '.env.local' : names.includes('.env') ? '.env' : '.env.local'
87}
88
89function asFile(value: unknown): string | undefined {
90 if (value === undefined) return undefined
91 const name = String(value)
92 return env.isEnvFile(name) ? name : undefined
93}
94
95function fitOf(value: string, pattern: string | undefined): EnvFit {
96 if (value === '' || !pattern) return null
97 try {
98 return new RegExp(pattern).test(value.trim()) ? 'ok' : 'off'
99 } catch {
100 return null
101 }
102}
103
104async function isIgnored($: Engine, file: string): Promise<boolean> {
105 const text = await $.fs.read('.gitignore').catch(() => '')
106 return text.split(/\r?\n/).some(row => {
107 const pattern = row.trim().replace(/^\//, '')
108 if (pattern === '' || pattern.startsWith('#') || pattern.startsWith('!')) return false
109 const glob = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$')
110 return glob.test(file)
111 })
112}
113
114// Ends an open request without a value and tells Claude why, so it doesn't
115// sit waiting for a message that will never come
116async function abandon($: Engine, how: 'skipped' | 'closed the pane without setting'): Promise<void> {
117 const asked = await read($, request)
118 if (!asked) return
119 await update($, request, () => null)
120 await update($, fit, () => null)
121 void $.prompt.submit({
122 text: `[env] The user ${how} ${asked.key}, so it is still not set in ${asked.file}. Carry on without it, or ask what they'd like to do.`,
123 })
124}
125
126type Block = { type: string; [field: string]: unknown }
127
128function scrubBlock(block: Block): Block {
129 if (block.type === 'text' && typeof block.text === 'string') {
130 const text = env.scrub(block.text, index)
131 return text === block.text ? block : { ...block, text }
132 }
133 if (block.type === 'tool_result') {
134 if (typeof block.content === 'string') {
135 const content = env.scrub(block.content, index)
136 return content === block.content ? block : { ...block, content }
137 }
138 if (Array.isArray(block.content)) {
139 const content: Block[] = (block.content as Block[]).map(scrubBlock)
140 return content.every((part, i) => part === (block.content as unknown[])[i]) ? block : { ...block, content }
141 }
142 }
143 return block
144}
145
146async function store($: Engine, target: string, key: string, value: string): Promise<void> {
147 await save($, target, env.set(await load($, target), key, value))
148 await update($, editing, () => null)
149 await update($, flash, () => `Saved ${key} in ${target}`)
150 const wanted = await read($, request)
151 if (wanted && wanted.key === key && wanted.file === target) {
152 await update($, request, () => null)
153 await update($, fit, () => null)
154 void $.prompt.submit({ text: `[env] ${key} is now set in ${target}. Continue.` })
155 }
156}
157
158// The card Claude's request draws: what, why, where to get it, and the
159// field to paste into, readable at a glance. In the pane, or in the band
160// above the prompt while the pane waits for a wider terminal.
161async function requestCard($: Engine, ui: ElementTable<'terminal' | 'desktop'>, asked: EnvRequest, where: 'pane' | 'band') {
162 const { Box, Text, Button, Input, Markdown } = ui
163 const pasted = await read($, fit)
164 return (
165 <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginBottom={where === 'pane' ? 1 : 0}>
166 <Text bold color="yellow">
167 Claude needs a value
168 </Text>
169 <Box gap={1} marginTop={1}>
170 <Text bold>{asked.key}</Text>
171 <Text dimColor>→ {asked.file}</Text>
172 </Box>
173 {asked.reason !== '' && <Text wrap="wrap">{asked.reason}</Text>}
174
175 {asked.steps.length > 0 && (
176 <Box flexDirection="column" marginTop={1}>
177 {asked.steps.map((step, i) => (
178 <Box key={`step:${i}`} gap={1}>
179 <Text bold color="yellow">
180 {String(i + 1)}.
181 </Text>
182 <Text wrap="wrap">{step}</Text>
183 </Box>
184 ))}
185 </Box>
186 )}
187
188 {asked.url && (
189 <Box gap={1} marginTop={1}>
190 <Markdown text={`[${asked.url}](${asked.url})`} />
191 <Button
192 key="copy-url"
193 label="copy link"
194 plain
195 dimColor
196 onPress={press => void $.ui.copy({ text: asked.url ?? '', surface: press.surface })}
197 />
198 </Box>
199 )}
200 {asked.format && <Text dimColor>Looks like: {asked.format}</Text>}
201
202 <Box marginTop={1}>
203 <Input
204 key="request"
205 label="Paste ▸ "
206 placeholder={`${asked.key}, then Enter`}
207 submitLabel="save"
208 autoFocus
209 onInput={value => void update($, fit, () => fitOf(value, asked.pattern))}
210 onSubmit={value => void store($, asked.file, asked.key, value.trim())}
211 />
212 </Box>
213 {pasted === 'ok' && <Text color="green">✓ looks right</Text>}
214 {pasted === 'off' && <Text color="red">✗ doesn't look like {asked.format ?? 'the expected format'}; Enter still saves</Text>}
215 <Box gap={1} marginTop={1}>
216 <Button key="skip" label="skip for now" plain dimColor onPress={() => void abandon($, 'skipped')} />
217 {where === 'band' && (
218 <Button
219 key="open-pane"
220 label="open /env pane"
221 plain
222 dimColor
223 onPress={async () => {
224 const opened = await $.ui.open({ id: PANE, title: 'env', focus: true })
225 await update($, isWaiting, () => !opened.isPlaced)
226 }}
227 />
228 )}
229 </Box>
230 {where === 'band' && <Text dimColor>Click the field or press ctrl+x tab to paste.</Text>}
231 </Box>
232 )
233}
234
235export const register: Register = on => {
236 on('session.start', async ($, e, next) => {
237 await refresh($)
238
239 await $.command.register({
240 name: 'env',
241 description: 'Edit .env files without showing values to Claude',
242 immediate: true,
243 })
244
245 await $.tool.register({
246 name: 'list',
247 description:
248 "Lists the project's .env files and their keys, with what each value looks like (set or empty, " +
249 'kind, length, a known prefix such as sk_live_) but never the value itself, plus keys missing ' +
250 'compared with .env.example. Use this instead of reading .env files, which is blocked.',
251 inputSchema: {
252 type: 'object',
253 properties: { file: { type: 'string', description: 'One env file, e.g. .env.local. Default: all.' } },
254 },
255 })
256 await $.tool.register({
257 name: 'request',
258 description:
259 'Asks the user to paste the value of one env variable into a card in the /env pane. You never ' +
260 'see the value. The user may be moving fast, so make the card easy to act on: give the exact ' +
261 'steps to find the value (where to click, what the field is labelled), the URL of the page it ' +
262 'lives on, and what a valid value looks like. After calling this, stop and wait: you get a ' +
263 'message once the value is saved, skipped, or the pane is closed.',
264 inputSchema: {
265 type: 'object',
266 properties: {
267 key: { type: 'string', description: 'The variable name the code reads, e.g. CLOUDFLARE_ACCOUNT_ID' },
268 file: { type: 'string', description: 'Default .env.local, else .env' },
269 reason: { type: 'string', description: 'Why it is needed, in under 12 words' },
270 steps: {
271 type: 'array',
272 items: { type: 'string' },
273 description:
274 '2-5 short imperative steps, one action each, naming the exact labels on screen. ' +
275 'E.g. ["Open the Cloudflare dashboard", "Pick any domain", "Copy Account ID from the right sidebar"]',
276 },
277 url: { type: 'string', description: 'The https page where the value can be found or created' },
278 format: { type: 'string', description: 'What a valid value looks like, e.g. "32-character hex"' },
279 pattern: { type: 'string', description: 'Optional regex the value should match, to flag a bad paste' },
280 },
281 required: ['key', 'reason', 'steps'],
282 },
283 })
284 await $.tool.register({
285 name: 'generate',
286 description:
287 'Sets an env variable to a new random secret (session secrets, signing keys, webhook ' +
288 'secrets you control). The value is written to the file and never returned. Refuses to ' +
289 'replace an existing value unless overwrite is true.',
290 inputSchema: {
291 type: 'object',
292 properties: {
293 key: { type: 'string' },
294 file: { type: 'string' },
295 bytes: { type: 'number', description: 'Random bytes, default 32' },
296 format: { type: 'string', enum: ['base64url', 'hex'] },
297 overwrite: { type: 'boolean' },
298 },
299 required: ['key'],
300 },
301 })
302 await $.tool.register({
303 name: 'copy',
304 description:
305 'Copies env values without reading them: one key (optionally renamed with as), or with no key ' +
306 "the whole file. from and to are env file names in this project's root (.env.local), or paths " +
307 "to an env file at the root of another of this repo's git worktrees (../app-feature/.env.local), " +
308 'e.g. to set up a new worktree. A whole-file copy into a file that does not exist yet copies it ' +
309 "as-is, comments included; into an existing file, keys the target already has with a different " +
310 'value are kept unless overwrite is true.',
311 inputSchema: {
312 type: 'object',
313 properties: {
314 key: { type: 'string', description: 'One key to copy. Omit to copy every key.' },
315 from: { type: 'string', description: 'An env file name, or a path to one in another worktree' },
316 to: { type: 'string', description: 'An env file name, or a path to one in another worktree' },
317 as: { type: 'string', description: 'Key name in the target file, with key. Default: same key.' },
318 overwrite: { type: 'boolean', description: 'Whole-file copy only: replace keys the target sets differently' },
319 },
320 required: ['from', 'to'],
321 },
322 })
323 await $.tool.register({
324 name: 'remove',
325 description:
326 'Removes a key from an env file by commenting it out, so the user can restore it. Ask the user before removing keys.',
327 inputSchema: {
328 type: 'object',
329 properties: { key: { type: 'string' }, file: { type: 'string' } },
330 required: ['key', 'file'],
331 },
332 })
333
334 await $.tool.register({
335 name: 'push',
336 description:
337 "Sends env values to a hosting service by running that service's own CLI, with {{KEY}} in argv " +
338 'or stdin replaced by the value from the env file. You never see the value, and the user ' +
339 'approves the command (shown with placeholders) before it runs. Prefer stdin, since arguments ' +
340 `show in the process list. Only these CLIs run: ${push.CLIS.join(', ')}. Examples: ` +
341 '{argv: ["vercel", "env", "add", "STRIPE_KEY", "production"], stdin: "{{STRIPE_KEY}}"}; ' +
342 '{argv: ["gh", "secret", "set", "STRIPE_KEY"], stdin: "{{STRIPE_KEY}}"}; ' +
343 '{argv: ["wrangler", "secret", "put", "STRIPE_KEY"], stdin: "{{STRIPE_KEY}}"}; ' +
344 '{argv: ["fly", "secrets", "import"], stdin: "STRIPE_KEY={{STRIPE_KEY}}"}. ' +
345 'Run one command per call. Commands that need no env value belong in Bash.',
346 inputSchema: {
347 type: 'object',
348 properties: {
349 argv: {
350 type: 'array',
351 items: { type: 'string' },
352 description: 'The command and its arguments, the CLI first. No shell: no pipes, quoting or $VARS.',
353 },
354 stdin: { type: 'string', description: 'Text written to the command\'s stdin, e.g. "{{STRIPE_KEY}}"' },
355 file: { type: 'string', description: 'The env file the values come from. Default .env.local, else .env' },
356 },
357 required: ['argv'],
358 },
359 })
360
361 await $.tool.register({
362 name: 'pull',
363 description:
364 "Fetches env values from a hosting or secrets service by running that service's own CLI, and " +
365 'writes them into an env file. You never see the values; you get back which keys were set. ' +
366 'Three shapes: the CLI prints KEY=value lines; the CLI writes a dotenv file, where ' +
367 `${pull.OUT} in argv stands for a temp file env reads back; or the CLI prints one value, named by key. ` +
368 'A key the file already has with a different value is kept unless overwrite is true, and then ' +
369 `the user is asked first. Only these CLIs run: ${push.CLIS.join(', ')}. Examples: ` +
370 `{argv: ["vercel", "env", "pull", "${pull.OUT}", "--environment=development", "--yes"]}; ` +
371 '{argv: ["doppler", "secrets", "download", "--no-file", "--format", "env"]}; ' +
372 '{argv: ["heroku", "config", "--shell", "-a", "my-app"]}; ' +
373 '{argv: ["op", "read", "op://Dev/Stripe/secret key"], key: "STRIPE_SECRET_KEY"}. ' +
374 'GitHub, Cloudflare and Fly secrets are write-only and cannot be pulled.',
375 inputSchema: {
376 type: 'object',
377 properties: {
378 argv: {
379 type: 'array',
380 items: { type: 'string' },
381 description: 'The command and its arguments, the CLI first. No shell: no pipes, quoting or $VARS.',
382 },
383 key: { type: 'string', description: 'When the CLI prints a single value: the key to store it under' },
384 keys: { type: 'array', items: { type: 'string' }, description: 'Only take these keys from KEY=value output' },
385 file: { type: 'string', description: 'The env file to write. Default .env.local, else .env' },
386 overwrite: { type: 'boolean', description: 'Replace keys the file already has with other values (the user is asked)' },
387 },
388 required: ['argv'],
389 },
390 })
391
392 // Notice edits made outside the pane (another editor, a script)
393 $.clock.every(2000, () => {
394 void $.fs.list().then(listing => {
395 const now = listing
396 .filter(entry => env.isEnvFile(entry.name))
397 .map(entry => `${entry.name}:${entry.mtimeMs}:${entry.size}`)
398 .join('|')
399 if (now !== signature) return refresh($)
400 })
401 // A waiting pane is placed once the terminal widens to its floor
402 void $.ui.panes().then(async panes => {
403 const pane = panes.find(p => p.id === PANE)
404 const waiting = pane !== undefined && !pane.isPlaced
405 if (waiting !== (await read($, isWaiting))) await update($, isWaiting, () => waiting)
406 })
407 })
408
409 return next(e)
410 })
411
412 on('command.run', { command: 'env' }, async $ => {
413 await refresh($)
414 const opened = await $.ui.open({ id: PANE, title: 'env', focus: true })
415 await update($, isWaiting, () => !opened.isPlaced)
416 return { text: 'Opened the env pane.' }
417 })
418
419 on('ui.close', async ($, e, next) => {
420 if (e.id === PANE) {
421 await update($, isWaiting, () => false)
422 if (e.origin.kind === 'person') await abandon($, 'closed the pane without setting')
423 }
424 return next(e)
425 })
426
427 // The fence: keep the built-in tools away from the raw files
428 on('tool.call', ($, e, next) => {
429 const tool = String(e.tool)
430 if (tool.startsWith(TOOL)) return next(e)
431 const input = e as unknown as Record<string, unknown>
432
433 if (['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool) && isProtectedPath(input.file_path)) {
434 return { deny: DENY }
435 }
436 if (tool === 'Grep') {
437 const reason = grepReason(input)
438 if (reason) return { deny: `${DENY} (Blocked because ${reason}.)` }
439 }
440 if (tool === 'Bash' && typeof input.command === 'string') {
441 const reason = bashReason(input.command)
442 if (reason) return { deny: `${DENY} (Blocked because ${reason}.)` }
443 }
444 return next(e)
445 })
446
447 // The backstop: any known value that reaches the conversation by any route
448 // is replaced with [env:KEY] before the row is stored or sent
449 on('session.append', ($, e, next) => {
450 if (index.length === 0) return next(e)
451 const content = e.message.content.map(scrubBlock)
452 const isChanged = content.some((block, i) => block !== e.message.content[i])
453 return isChanged ? next({ ...e, message: { ...e.message, content } }) : next(e)
454 })
455
456 on('session.send', ($, e, next) =>
457 index.length === 0 ? next(e) : next({ ...e, text: env.scrub(e.text, index) }),
458 )
459
460 on('tool.call', { tool: 'mcp__env__list' }, async ($, e) => {
461 const input = e as unknown as Record<string, unknown>
462 await refresh($)
463 const names = await read($, files)
464 const wanted = input.file === undefined ? names : names.filter(name => name === input.file)
465 if (wanted.length === 0) {
466 return { result: names.length === 0 ? 'No .env files in the project root.' : `No such file. Files: ${names.join(', ')}` }
467 }
468
469 const template = names.find(env.isTemplate)
470 const templateKeys = template ? env.entries(await load($, template)).map(entry => entry.key) : []
471 const out: string[] = []
472 for (const name of wanted) {
473 const list = env.entries(await load($, name))
474 out.push(`${name} (${list.length} keys${env.isTemplate(name) ? ', template' : ''})`)
475 for (const entry of list) {
476 const v = entry.value
477 const look = v === '' ? 'empty' : `${env.kind(v)}, ${v.length} chars, looks like ${env.shape(v)}`
478 out.push(` ${entry.key}: ${look}`)
479 }
480 if (!env.isTemplate(name) && templateKeys.length > 0) {
481 const have = new Set(list.map(entry => entry.key))
482 const missing = templateKeys.filter(key => !have.has(key))
483 if (missing.length > 0) out.push(` missing compared with ${template}: ${missing.join(', ')}`)
484 }
485 }
486 out.push('Values are hidden by design. Use mcp__env__request to have the user set one.')
487 return { result: out.join('\n') }
488 })
489
490 on('tool.call', { tool: 'mcp__env__request' }, async ($, e) => {
491 const input = e as unknown as Record<string, unknown>
492 const key = String(input.key ?? '')
493 if (!KEY_NAME.test(key)) return { result: `"${key}" is not a valid env key name.` }
494 const file = asFile(input.file) ?? (await defaultFile($))
495 const text = (value: unknown, max: number) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : undefined)
496 const steps = Array.isArray(input.steps) ? input.steps.map(step => String(step).slice(0, 160)).slice(0, 6) : []
497 const url = text(input.url, 300)
498
499 await update($, request, () => ({
500 key,
501 file,
502 reason: text(input.reason, 200) ?? '',
503 steps,
504 url: url && /^https?:\/\//.test(url) ? url : undefined,
505 format: text(input.format, 80),
506 pattern: text(input.pattern, 200),
507 }))
508 await update($, fit, () => null)
509 await update($, active, () => file)
510 await update($, editing, () => null)
511 // Opened by Claude, not the person, so a narrow terminal leaves the pane
512 // waiting undrawn; the card then shows in the band above the prompt
513 const opened = await $.ui.open({ id: PANE, title: 'env', focus: true })
514 await update($, isWaiting, () => !opened.isPlaced)
515 const wait = 'Stop here and wait; you will get a message once it is saved, skipped or the pane is closed.'
516 if (!opened.isPlaced) {
517 $.ui.toast(`Claude needs ${key}: paste it above the prompt`)
518 return {
519 result:
520 `The terminal is too narrow for the /env pane to open on its own, so the request for ${key} (${file}) ` +
521 `shows as a card above the prompt instead. Tell the user in one line to paste it there, or type /env ` +
522 `to open the full pane. ${wait}`,
523 }
524 }
525 $.ui.toast(`Claude needs ${key}`)
526 return { result: `Asked the user to set ${key} in ${file} in the /env pane. ${wait}` }
527 })
528
529 on('tool.call', { tool: 'mcp__env__generate' }, async ($, e) => {
530 const input = e as unknown as Record<string, unknown>
531 const key = String(input.key ?? '')
532 if (!KEY_NAME.test(key)) return { result: `"${key}" is not a valid env key name.` }
533 const file = asFile(input.file) ?? (await defaultFile($))
534 const bytes = Math.min(128, Math.max(16, Number(input.bytes ?? 32) || 32))
535 const format = input.format === 'hex' ? 'hex' : 'base64url'
536 const lines = await load($, file)
537 if (env.get(lines, key) && input.overwrite !== true) {
538 return { result: `${key} already has a value in ${file}. Pass overwrite: true to replace it (confirm with the user first).` }
539 }
540 await save($, file, env.set(lines, key, env.randomSecret(bytes, format)))
541 $.ui.toast(`Claude generated ${key} in ${file}`)
542 return { result: `Set ${key} in ${file} to ${bytes} random bytes (${format}). The value is not shown.` }
543 })
544
545 on('tool.call', { tool: 'mcp__env__copy' }, async ($, e) => {
546 const input = e as unknown as Record<string, unknown>
547 const git = async (...args: string[]) => {
548 const ran = await $.process.run(['git', ...args]).catch(() => null)
549 return ran?.exitCode === 0 ? ran.stdout.trim() : ''
550 }
551 const isPath = [input.from, input.to].some(value => typeof value === 'string' && value.includes('/'))
552 // Only a path needs git: where the session sits, and which worktrees exist
553 const root = isPath ? await git('rev-parse', '--show-toplevel') : ''
554 const prefix = isPath ? await git('rev-parse', '--show-prefix') : ''
555 const cwd = prefix ? `${root}/${prefix.replace(/\/$/, '')}` : root
556 const trees = isPath ? worktree.parseWorktrees(await git('worktree', 'list', '--porcelain')) : []
557 const from = worktree.resolve(input.from, cwd, trees)
558 const to = worktree.resolve(input.to, cwd, trees)
559 if ('error' in from) return { result: from.error }
560 if ('error' in to) return { result: to.error }
561 if (from.path === to.path) return { result: 'from and to are the same file.' }
562 if (env.isTemplate(basename(to.path))) {
563 return { result: `${to.label} is a template, usually committed, so env won't copy values into it.` }
564 }
565
566 const source = await load($, from.path)
567 if (input.key !== undefined) {
568 const key = String(input.key)
569 const target = String(input.as ?? key)
570 if (!KEY_NAME.test(target)) return { result: `"${target}" is not a valid env key name.` }
571 const value = env.get(source, key)
572 if (value === undefined) return { result: `${key} is not set in ${from.label}.` }
573 await save($, to.path, env.set(await load($, to.path), target, value))
574 return { result: `Copied ${key} from ${from.label} to ${to.label}${target === key ? '' : ` as ${target}`}.` }
575 }
576
577 const keys = env.entries(source).map(entry => entry.key)
578 if (keys.length === 0) return { result: `${from.label} has no keys to copy.` }
579 if (!(await $.fs.exists(to.path))) {
580 await save($, to.path, source)
581 $.ui.toast(`Copied ${from.label} to ${to.label}`)
582 return { result: `Copied ${from.label} to ${to.label} as-is: ${keys.join(', ')}. Values not shown.` }
583 }
584 const values = Object.fromEntries(env.entries(source).map(entry => [entry.key, entry.value]))
585 const merged = pull.merge(await load($, to.path), values, input.overwrite === true)
586 if (merged.added.length + merged.changed.length > 0) await save($, to.path, merged.lines)
587 const report = [`Copied ${from.label} into ${to.label}, values not shown:`]
588 if (merged.added.length > 0) report.push(` set: ${merged.added.join(', ')}`)
589 if (merged.changed.length > 0) report.push(` replaced: ${merged.changed.join(', ')}`)
590 if (merged.same.length > 0) report.push(` already matching: ${merged.same.join(', ')}`)
591 if (merged.kept.length > 0) {
592 report.push(` kept the target's own value: ${merged.kept.join(', ')} (overwrite: true replaces them; ask the user first)`)
593 }
594 return { result: report.join('\n') }
595 })
596
597 on('tool.call', { tool: 'mcp__env__remove' }, async ($, e) => {
598 const input = e as unknown as Record<string, unknown>
599 const key = String(input.key ?? '')
600 const file = asFile(input.file)
601 if (!file) return { result: 'file must be an env file.' }
602 const lines = await load($, file)
603 if (env.get(lines, key) === undefined) return { result: `${key} is not in ${file}.` }
604 await save($, file, env.commentOut(lines, key, `removed by Claude; uncomment to restore`))
605 return { result: `Commented out ${key} in ${file}.` }
606 })
607
608 on('tool.call', { tool: 'mcp__env__push' }, async ($, e) => {
609 const input = e as unknown as Record<string, unknown>
610 const plan = push.check(input.argv, input.stdin)
611 if ('error' in plan) return { result: plan.error }
612 const file = asFile(input.file) ?? (await defaultFile($))
613 const cli = plan.argv[0] ?? ''
614
615 const lines = await load($, file)
616 const values: Record<string, string> = {}
617 for (const key of plan.keys) {
618 const value = env.get(lines, key)
619 if (value) values[key] = value
620 }
621 const missing = plan.keys.filter(key => values[key] === undefined)
622 if (missing.length > 0) {
623 return { result: `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not set in ${file}. Use mcp__env__request to have the user set ${missing.length === 1 ? 'it' : 'them'} first.` }
624 }
625
626 const command = push.shown(plan)
627 const yes = 'Run it'
628 const answer = await $.ui
629 .ask(`Run \`${command}\` with ${plan.keys.join(', ')} filled in from ${file}?`, {
630 header: 'env',
631 options: [yes, "Don't run it"],
632 })
633 .catch(() => null)
634 if (answer !== yes) {
635 const said = answer && answer !== "Don't run it" ? ` They said: ${env.scrub(answer, index)}` : ''
636 return { result: `The user did not approve \`${command}\`, so nothing ran.${said}` }
637 }
638
639 const ran = await $.process
640 .run(
641 plan.argv.map(arg => push.fill(arg, values)),
642 { stdin: plan.stdin === undefined ? undefined : push.fill(plan.stdin, values), timeoutMs: 120_000 },
643 )
644 .catch((error: unknown) => ({ error: push.redact(String(error), values) }))
645 if ('error' in ran) {
646 return { result: `\`${command}\` could not run: ${ran.error}. Is ${cli} installed and on PATH?` }
647 }
648
649 const clean = (text: string) => push.tail(push.redact(env.scrub(text, index), values))
650 const out = [`\`${command}\` exited ${ran.exitCode}.`]
651 if (ran.stdout.trim()) out.push(`stdout:\n${clean(ran.stdout)}`)
652 if (ran.stderr.trim()) out.push(`stderr:\n${clean(ran.stderr)}`)
653 if (ran.exitCode !== 0) {
654 out.push(`If ${cli} isn't logged in or linked, ask the user to run \`! ${cli} login\` (or link the project) and try again.`)
655 } else {
656 $.ui.toast(`Sent ${plan.keys.join(', ')} with ${cli}`)
657 }
658 return { result: out.join('\n') }
659 })
660
661 on('tool.call', { tool: 'mcp__env__pull' }, async ($, e) => {
662 const input = e as unknown as Record<string, unknown>
663 const plan = pull.check(input.argv, input.key, input.keys)
664 if ('error' in plan) return { result: plan.error }
665 const file = asFile(input.file) ?? (await defaultFile($))
666 const cli = plan.argv[0] ?? ''
667 const command = push.shown({ argv: plan.argv, keys: [] })
668 const login = `If ${cli} isn't logged in or linked, ask the user to run \`! ${cli} login\` (or link the project) and try again.`
669
670 // A CLI that writes a file gets a fresh temp folder, removed afterwards
671 let dir: string | undefined
672 if (plan.usesFile) {
673 const made = await $.process.run(['mktemp', '-d']).catch(() => null)
674 dir = made?.exitCode === 0 ? made.stdout.trim() : undefined
675 if (!dir) return { result: 'Could not make a temp folder for the pulled file, so nothing ran.' }
676 }
677 const out = `${dir}/pulled.env`
678
679 try {
680 const ran = await $.process
681 .run(
682 plan.argv.map(arg => (arg === pull.OUT ? out : arg)),
683 { timeoutMs: 120_000 },
684 )
685 .catch((error: unknown) => ({ error: env.scrub(String(error), index) }))
686 if ('error' in ran) return { result: `\`${command}\` could not run: ${ran.error}. Is ${cli} installed and on PATH?` }
687
688 const text = dir ? String(await $.fs.read(out).catch(() => '')) : ran.stdout
689 const values = pull.incoming(plan, text)
690 const clean = (t: string) => push.tail(push.redact(env.scrub(t, index), values))
691 const stderr = ran.stderr.trim() ? `stderr:\n${clean(ran.stderr)}` : ''
692 if (ran.exitCode !== 0) {
693 return { result: [`\`${command}\` exited ${ran.exitCode}, so nothing was written.`, stderr, login].filter(Boolean).join('\n') }
694 }
695 if (Object.keys(values).length === 0) {
696 const wanted = plan.key ? 'a value' : plan.keys ? `any of ${plan.keys.join(', ')}` : 'any KEY=value lines'
697 return { result: [`\`${command}\` gave ${wanted}, so nothing was written.`, stderr].filter(Boolean).join('\n') }
698 }
699
700 const lines = await load($, file)
701 let overwrite = input.overwrite === true
702 const clash = pull.conflicts(lines, values)
703 if (overwrite && clash.length > 0) {
704 const yes = 'Replace them'
705 const answer = await $.ui
706 .ask(`Replace ${clash.join(', ')} in ${file} with the values from \`${command}\`?`, {
707 header: 'env',
708 options: [yes, 'Keep mine'],
709 })
710 .catch(() => null)
711 overwrite = answer === yes
712 }
713 const merged = pull.merge(lines, values, overwrite)
714 if (merged.added.length + merged.changed.length > 0) {
715 await save($, file, merged.lines)
716 $.ui.toast(`Pulled ${merged.added.length + merged.changed.length} keys into ${file} with ${cli}`)
717 }
718
719 const report = [`Ran \`${command}\`, values not shown. In ${file}:`]
720 if (merged.added.length > 0) report.push(` set: ${merged.added.join(', ')}`)
721 if (merged.changed.length > 0) report.push(` replaced: ${merged.changed.join(', ')}`)
722 if (merged.same.length > 0) report.push(` already matching: ${merged.same.join(', ')}`)
723 if (merged.kept.length > 0) {
724 report.push(
725 ` kept the file's own value: ${merged.kept.join(', ')}` +
726 (input.overwrite === true ? ' (the user chose to keep them)' : ' (pass overwrite: true to replace; the user will be asked)'),
727 )
728 }
729 return { result: report.join('\n') }
730 } finally {
731 if (dir) await $.process.run(['rm', '-rf', dir]).catch(() => undefined)
732 }
733 })
734
735 // While the pane waits for a wider terminal, Claude's request shows here
736 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
737 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
738 if (e.props.hasSurvey || !(await read($, isWaiting))) return next(e)
739 const asked = await read($, request)
740 if (!asked) return next(e)
741 // isWaiting trails the surface by up to a poll; ask the surface itself
742 // so the card leaves the moment the pane is placed
743 const pane = (await $.ui.panes()).find(p => p.id === PANE)
744 if (!pane || pane.isPlaced) return next(e)
745 return requestCard($, $.ui.resolve(e), asked, 'band')
746 })
747
748 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
749 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
750 const { Text } = $.ui.resolve(e)
751 return <Text>Open a terminal or the desktop app to edit env files.</Text>
752 }
753 const ui = $.ui.resolve(e)
754 const { Box, Text, Button, Input } = ui
755
756 await read($, rev)
757 const names = await read($, files)
758 const file = await read($, active)
759 const edit = await read($, editing)
760 const shown = await read($, revealed)
761 const asked = await read($, request)
762 const pending = await read($, confirm)
763 const note = await read($, flash)
764
765 const lines = file ? await load($, file) : []
766 const list = env.entries(lines)
767 const template = names.find(env.isTemplate)
768 const isTemplate = env.isTemplate(file)
769 const missing =
770 template && !isTemplate
771 ? env.entries(await load($, template)).map(entry => entry.key).filter(key => !list.some(entry => entry.key === key))
772 : []
773 const isUnignored = file !== '' && !isTemplate && !(await isIgnored($, file))
774 const keyWidth = Math.min(32, Math.max(8, ...list.map(entry => entry.key.length)) + 2)
775
776 const say = (text: string | null) => update($, flash, () => text)
777
778 const card = asked && (await requestCard($, ui, asked, 'pane'))
779
780 const valueEditor = (key: string) => (
781 <Box flexDirection="column" marginBottom={1}>
782 <Input
783 key="value"
784 label={`${key} = `}
785 placeholder="paste the value, Enter saves"
786 submitLabel="save"
787 autoFocus
788 onSubmit={value => void store($, file, key, value)}
789 />
790 <Box gap={1}>
791 <Button key="gen" label="generate random" onPress={() => void store($, file, key, env.randomSecret(32, 'base64url'))} />
792 <Button key="cancel" label="cancel" onPress={() => void update($, editing, () => null)} />
793 </Box>
794 </Box>
795 )
796
797 const row = (entry: env.Entry) => {
798 const k = entry.key
799 if (edit && edit.file === file && edit.key === k) return valueEditor(k)
800 const isShown = shown === `${file}:${k}`
801 return (
802 <Box key={`row:${k}`} gap={1}>
803 <Box width={keyWidth} flexShrink={0}>
804 <Text bold wrap="truncate">{k}</Text>
805 </Box>
806 <Box flexGrow={1}>
807 <Text dimColor={!isShown} wrap="truncate">{isShown ? entry.value || '(empty)' : env.shape(entry.value, true)}</Text>
808 </Box>
809 <Button
810 key={`copy:${k}`}
811 label="copy"
812 plain
813 onPress={async press => {
814 const { isCopied } = await $.ui.copy({ text: entry.value, surface: press.surface })
815 await say(isCopied ? `Copied ${k}` : `Could not copy ${k}`)
816 }}
817 />
818 <Button key={`edit:${k}`} label="edit" plain onPress={() => void update($, editing, () => ({ file, key: k }))} />
819 <Button
820 key={`show:${k}`}
821 label={isShown ? 'hide' : 'show'}
822 plain
823 onPress={() => void update($, revealed, now => (now === `${file}:${k}` ? null : `${file}:${k}`))}
824 />
825 {pending === `${file}:${k}` ? (
826 <Button
827 key={`really:${k}`}
828 label="really delete?"
829 variant="primary"
830 onPress={async () => {
831 await save($, file, env.remove(await load($, file), k))
832 await update($, confirm, () => null)
833 await say(`Deleted ${k} from ${file}`)
834 }}
835 />
836 ) : (
837 <Button key={`del:${k}`} label="del" plain dimColor onPress={() => void update($, confirm, () => `${file}:${k}`)} />
838 )}
839 </Box>
840 )
841 }
842
843 const isNewKey = edit !== null && edit.file === file && edit.key !== null && !list.some(entry => entry.key === edit.key)
844
845 return (
846 <Box flexDirection="column">
847 {card}
848
849 <Box gap={1} flexWrap="wrap" marginBottom={1}>
850 {names.map(name => (
851 <Button
852 key={`file:${name}`}
853 label={name}
854 variant={name === file ? 'primary' : undefined}
855 dimColor={name !== file}
856 onPress={() => void update($, active, () => name)}
857 />
858 ))}
859 {!names.includes('.env.local') && (
860 <Button key="create" label="+ .env.local" plain dimColor onPress={() => void save($, '.env.local', [])} />
861 )}
862 </Box>
863
864 {isUnignored && <Text color="red">{file} is not in .gitignore</Text>}
865 {note && <Text dimColor>{note}</Text>}
866 {names.length === 0 && <Text dimColor>No .env files in the project root yet.</Text>}
867
868 {list.map(row)}
869 {isNewKey && edit?.key && valueEditor(edit.key)}
870
871 {edit && edit.file === file && edit.key === null ? (
872 <Input
873 key="name"
874 label="New key: "
875 placeholder="NAME, Enter to continue"
876 autoFocus
877 onSubmit={name => {
878 const key = name.trim()
879 if (KEY_NAME.test(key)) void update($, editing, () => ({ file, key }))
880 else void say(`"${key}" is not a valid key name`)
881 }}
882 />
883 ) : (
884 file !== '' && <Button key="add" label="+ add key" plain onPress={() => void update($, editing, () => ({ file, key: null }))} />
885 )}
886
887 {missing.length > 0 && (
888 <Box flexDirection="column" marginTop={1}>
889 <Text dimColor>Missing compared with {template}:</Text>
890 <Box gap={1} flexWrap="wrap">
891 {missing.map(key => (
892 <Button key={`fill:${key}`} label={key} plain dimColor onPress={() => void update($, editing, () => ({ file, key }))} />
893 ))}
894 </Box>
895 </Box>
896 )}
897 </Box>
898 )
899 })
900}
901hooks/dotenv.ts 189 lines1// A .env parser that keeps the file as written: comments, blank lines, order
2// and quoting survive a round trip, and only the entries we change are redrawn.
3
4export type Raw = { kind: 'raw'; raw: string }
5export type Entry = { kind: 'entry'; key: string; value: string; prefix: string; raw: string }
6export type Line = Raw | Entry
7
8const ASSIGN = /^\s*(export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s?(.*)$/
9const BARE = /^[A-Za-z0-9_\-.,:/@+=%~]*$/
10
11export function parse(text: string): Line[] {
12 const rows = text.split(/\r?\n/)
13 const lines: Line[] = []
14
15 for (let i = 0; i < rows.length; i++) {
16 const row = rows[i] ?? ''
17 const match = ASSIGN.exec(row)
18 if (!match) {
19 lines.push({ kind: 'raw', raw: row })
20 continue
21 }
22
23 const [, exported = '', key = '', rest = ''] = match
24 const quote = rest.trimStart()[0]
25 let value: string
26 let raw = row
27
28 if (quote === '"' || quote === "'" || quote === '`') {
29 // A quoted value may run over several lines until its closing quote
30 let body = rest.trimStart().slice(1)
31 let end = closing(body, quote)
32 let j = i
33 while (end < 0 && j + 1 < rows.length) {
34 j += 1
35 body += '\n' + (rows[j] ?? '')
36 end = closing(body, quote)
37 }
38 if (end < 0) {
39 // Never closed: keep the line as it stands rather than swallow the file
40 lines.push({ kind: 'raw', raw: row })
41 continue
42 }
43 raw = rows.slice(i, j + 1).join('\n')
44 i = j
45 value = body.slice(0, end)
46 if (quote === '"') {
47 value = value.replace(/\\([nr"\\])/g, (_, c: string) => (c === 'n' ? '\n' : c === 'r' ? '\r' : c))
48 }
49 } else {
50 value = rest.replace(/\s+#.*$/, '').trim()
51 }
52
53 lines.push({ kind: 'entry', key, value, prefix: exported, raw })
54 }
55
56 return lines
57}
58
59function closing(body: string, quote: string): number {
60 for (let k = 0; k < body.length; k++) {
61 if (quote === '"' && body[k] === '\\') {
62 k += 1
63 } else if (body[k] === quote) {
64 return k
65 }
66 }
67 return -1
68}
69
70export function quote(value: string): string {
71 if (BARE.test(value)) return value
72 if (!value.includes("'") && !value.includes('\n')) return `'${value}'`
73 return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`
74}
75
76export function serialize(lines: Line[]): string {
77 return lines.map(line => line.raw).join('\n')
78}
79
80export function entries(lines: Line[]): Entry[] {
81 // dotenv takes the last of a repeated key, so the list does too
82 const byKey = new Map<string, Entry>()
83 for (const line of lines) {
84 if (line.kind === 'entry') {
85 byKey.delete(line.key)
86 byKey.set(line.key, line)
87 }
88 }
89 return [...byKey.values()]
90}
91
92export function get(lines: Line[], key: string): string | undefined {
93 return entries(lines).find(entry => entry.key === key)?.value
94}
95
96export function set(lines: Line[], key: string, value: string): Line[] {
97 const at = lines.findLastIndex(line => line.kind === 'entry' && line.key === key)
98 if (at >= 0) {
99 const old = lines[at] as Entry
100 const next: Entry = { ...old, value, raw: `${old.prefix}${key}=${quote(value)}` }
101 return lines.map((line, i) => (i === at ? next : line))
102 }
103
104 const added: Entry = { kind: 'entry', key, value, prefix: '', raw: `${key}=${quote(value)}` }
105 const last = lines[lines.length - 1]
106 // Keep the file's trailing newline after the new line
107 if (last?.kind === 'raw' && last.raw === '') {
108 return [...lines.slice(0, -1), added, last]
109 }
110 return [...lines, added, { kind: 'raw', raw: '' }]
111}
112
113export function remove(lines: Line[], key: string): Line[] {
114 return lines.filter(line => !(line.kind === 'entry' && line.key === key))
115}
116
117export function commentOut(lines: Line[], key: string, note: string): Line[] {
118 return lines.map(line =>
119 line.kind === 'entry' && line.key === key
120 ? { kind: 'raw', raw: `# ${note}\n` + line.raw.split('\n').map(row => `# ${row}`).join('\n') }
121 : line,
122 )
123}
124
125export function isEnvFile(name: string): boolean {
126 return /^\.env(\.[\w.-]+)?$/.test(name)
127}
128
129export function isTemplate(name: string): boolean {
130 return /^\.env\.(example|sample|template|defaults|dist)$/.test(name)
131}
132
133// What a value looks like, without saying what it is. `showTail` adds the
134// last four characters, for the person's screen only.
135export function shape(value: string, showTail = false): string {
136 if (value === '') return '(empty)'
137 const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(value)
138 const prefix =
139 scheme?.[0] ??
140 /^(sk|pk|rk|whsec|ghp|gho|ghs|github_pat|xox[abpr]|sk-ant|sk-proj|re|AKIA)[_-]((live|test)[_-])?/i.exec(value)?.[0] ??
141 (value.startsWith('eyJ') ? 'eyJ' : '')
142 const tail = showTail && value.length > 12 ? value.slice(-4) : ''
143 return `${prefix}${'•'.repeat(prefix || tail ? 4 : 8)}${tail}`
144}
145
146export function kind(value: string): string {
147 if (value === '') return 'empty'
148 if (/^(true|false)$/i.test(value)) return 'boolean'
149 if (/^-?\d+(\.\d+)?$/.test(value)) return 'number'
150 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return `url (${value.split(':')[0]})`
151 if (/^eyJ[\w-]+\.[\w-]+\.[\w-]*$/.test(value)) return 'jwt'
152 if (/^[0-9a-f]+$/i.test(value)) return 'hex'
153 return 'string'
154}
155
156export function isSecretish(value: string): boolean {
157 return value.length >= 8 && !/^(true|false|-?\d+(\.\d+)?)$/i.test(value)
158}
159
160// Longest first, so a value inside another is never cut out of it
161export type Index = ReadonlyArray<readonly [value: string, key: string]>
162
163export function buildIndex(files: Record<string, Line[]>): Index {
164 const seen = new Map<string, string>()
165 for (const [file, lines] of Object.entries(files)) {
166 if (isTemplate(file)) continue
167 for (const entry of entries(lines)) {
168 if (isSecretish(entry.value)) seen.set(entry.value, entry.key)
169 }
170 }
171 return [...seen].sort((a, b) => b[0].length - a[0].length)
172}
173
174export function scrub(text: string, index: Index): string {
175 let out = text
176 for (const [value, key] of index) {
177 if (out.includes(value)) out = out.split(value).join(`[env:${key}]`)
178 }
179 return out
180}
181
182export function randomSecret(bytes: number, format: 'base64url' | 'hex'): string {
183 const buf = crypto.getRandomValues(new Uint8Array(bytes))
184 if (format === 'hex') return [...buf].map(b => b.toString(16).padStart(2, '0')).join('')
185 let bin = ''
186 for (const b of buf) bin += String.fromCharCode(b)
187 return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
188}
189hooks/guard.ts 57 lines1import { isEnvFile, isTemplate } from './dotenv'
2
3// Best effort: the scrubber in register.tsx is what actually keeps values out
4// of the conversation. This only stops the obvious ways of reading them.
5
6const ENV_TOKEN = /(?:^|[\s'"=/<>|;&(])(\.env(?:\.[\w.-]+)?)(?=$|[\s'";|&)<>])/g
7const HARMLESS = /^\s*(ls|stat|test|\[|git\s+(status|check-ignore|ls-files))\b/
8const DUMPS_ENV = /(?:^|[;&|(]\s*|\$\(\s*)(printenv\b|env\s*(?:$|[|;&>)])|export\s+-p\b|set\s*(?:$|[|;&>]))/
9// A shell or interpreter runs its quoted arguments as code, so those stay checked
10const RUNS_CODE = /(?:^|[\s;&|(])(?:sh|bash|zsh|fish|dash|eval|node|deno|bun|python[\d.]*|ruby|perl|php|osascript)\b/
11const QUOTED = /"(?:[^"\\]|\\.)*"|'[^']*'/g
12
13export function basename(path: string): string {
14 return path.split(/[\\/]/).pop() ?? path
15}
16
17export function isProtectedPath(path: unknown): boolean {
18 if (typeof path !== 'string') return false
19 const name = basename(path)
20 return isEnvFile(name) && !isTemplate(name)
21}
22
23// Text that only mentions a file name: heredoc bodies, commit messages, lines
24// appended to .gitignore, and quoted sentences (a PR body, a prompt handed to
25// another agent). Without this, writing a README that says ".env" or running
26// `echo .env >> .gitignore` would be refused. A quoted argument counts as a
27// sentence when it has a space and no command substitution in it.
28function withoutProse(command: string): string {
29 const code = command
30 .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g, '<<heredoc')
31 .replace(/(\bgit\s+commit\b[^;&|\n]*?\s-m\s*)("(?:[^"\\]|\\.)*"|'[^']*')/g, '$1msg')
32 .replace(/\b(echo|printf)\b[^;&|\n]*>>?\s*\S*\.gitignore\b/g, 'gitignore-edit')
33 if (RUNS_CODE.test(code)) return code
34 return code.replace(QUOTED, quoted => (/\s/.test(quoted) && !/\$\(|`/.test(quoted) ? '"prose"' : quoted))
35}
36
37export function bashReason(command: string): string | undefined {
38 const code = withoutProse(command)
39 if (DUMPS_ENV.test(code)) return 'it prints the environment'
40 // Each command in a chain on its own, so `cd app && git check-ignore .env`
41 // passes while `ls && cat .env` doesn't
42 for (const segment of code.split(/&&|\|\||[;|\n]/)) {
43 if (HARMLESS.test(segment)) continue
44 for (const match of segment.matchAll(ENV_TOKEN)) {
45 const name = match[1] ?? ''
46 if (!isTemplate(name)) return `it touches ${name}`
47 }
48 }
49 return undefined
50}
51
52export function grepReason(input: Record<string, unknown>): string | undefined {
53 if (isProtectedPath(input.path)) return `it searches ${input.path}`
54 if (typeof input.glob === 'string' && /(^|[/{,])\.env/.test(input.glob)) return 'its glob includes .env files'
55 return undefined
56}
57hooks/pull.ts 77 lines1// mcp__env__pull runs a hosting or secrets CLI (vercel, doppler, op...) and
2// writes what it prints into an env file, so values come down from the
3// service without passing through the conversation. The reverse of push.
4
5import * as env from './dotenv'
6import { CLIS, keysIn } from './push'
7
8// Stands for a fresh temp file, for CLIs that write a file rather than print
9// (`vercel env pull {{@file}}`). The @ keeps it from naming a real key.
10export const OUT = '{{@file}}'
11
12export type Plan = { argv: string[]; key?: string; keys?: string[]; usesFile: boolean }
13
14const KEY_NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/
15
16export function check(argv: unknown, key: unknown, keys: unknown): Plan | { error: string } {
17 if (!Array.isArray(argv) || argv.length === 0 || argv.length > 64 || !argv.every(arg => typeof arg === 'string')) {
18 return { error: 'argv must be a non-empty list of strings, the CLI first.' }
19 }
20 const cli = argv[0] as string
21 if (!CLIS.includes(cli)) {
22 return { error: `env only runs hosting CLIs, by bare name: ${CLIS.join(', ')}. "${cli}" is not one of them.` }
23 }
24 if (keysIn(argv.slice(1)).length > 0) {
25 return { error: 'pull does not fill in {{KEY}} placeholders; only push sends values out.' }
26 }
27 if (key !== undefined && (typeof key !== 'string' || !KEY_NAME.test(key))) return { error: `"${key}" is not a valid env key name.` }
28 if (keys !== undefined && (!Array.isArray(keys) || !keys.every(k => typeof k === 'string' && KEY_NAME.test(k)))) {
29 return { error: 'keys must be a list of env key names.' }
30 }
31 const usesFile = argv.includes(OUT)
32 if (usesFile && key !== undefined) return { error: `Use either ${OUT} (a file of KEY=value lines) or key (one printed value), not both.` }
33 return { argv: argv as string[], key, keys: keys as string[] | undefined, usesFile }
34}
35
36// What the CLI gave: one value it printed, or the KEY=value lines it printed
37// or wrote. `op read` and friends end the value with a newline; drop just that.
38export function incoming(plan: Plan, text: string): Record<string, string> {
39 if (plan.key) {
40 const value = text.replace(/\r?\n$/, '')
41 return value === '' ? {} : { [plan.key]: value }
42 }
43 const values: Record<string, string> = {}
44 for (const entry of env.entries(env.parse(text))) {
45 if (!plan.keys || plan.keys.includes(entry.key)) values[entry.key] = entry.value
46 }
47 return values
48}
49
50export type Merge = { lines: env.Line[]; added: string[]; changed: string[]; same: string[]; kept: string[] }
51
52// A key the file already has with another value is kept unless overwrite is
53// set: the local value may be the one the user means to use
54export function merge(lines: env.Line[], values: Record<string, string>, overwrite: boolean): Merge {
55 const out: Merge = { lines, added: [], changed: [], same: [], kept: [] }
56 for (const [key, value] of Object.entries(values)) {
57 const now = env.get(out.lines, key)
58 if (now === value) out.same.push(key)
59 else if (now !== undefined && now !== '' && !overwrite) out.kept.push(key)
60 else {
61 ;(now === undefined || now === '' ? out.added : out.changed).push(key)
62 out.lines = env.set(out.lines, key, value)
63 }
64 }
65 return out
66}
67
68// Keys the file would lose its own value for, so the user is asked first
69export function conflicts(lines: env.Line[], values: Record<string, string>): string[] {
70 return Object.entries(values)
71 .filter(([key, value]) => {
72 const now = env.get(lines, key)
73 return now !== undefined && now !== '' && now !== value
74 })
75 .map(([key]) => key)
76}
77hooks/push.ts 79 lines1// mcp__env__push runs a hosting provider's own CLI (vercel, gh, wrangler...)
2// with values from an env file filled in for {{KEY}} placeholders, so a
3// secret reaches the service without passing through the conversation.
4// The CLIs already hold the user's login; env only fills in the blanks.
5
6// Only these run, so a filled-in value can't be handed to curl or a script.
7// Bare names, looked up on PATH like any shell would.
8export const CLIS = [
9 'aws',
10 'az',
11 'doppler',
12 'firebase',
13 'fly',
14 'flyctl',
15 'gcloud',
16 'gh',
17 'heroku',
18 'netlify',
19 'op',
20 'railway',
21 'render',
22 'supabase',
23 'vercel',
24 'wrangler',
25]
26
27const PLACEHOLDER = /\{\{([A-Za-z_][A-Za-z0-9_.-]*)\}\}/g
28
29export type Plan = { argv: string[]; stdin?: string; keys: string[] }
30
31export function keysIn(texts: string[]): string[] {
32 const keys = new Set<string>()
33 for (const text of texts) for (const match of text.matchAll(PLACEHOLDER)) keys.add(match[1] ?? '')
34 return [...keys]
35}
36
37export function check(argv: unknown, stdin: unknown): Plan | { error: string } {
38 if (!Array.isArray(argv) || argv.length === 0 || argv.length > 64 || !argv.every(arg => typeof arg === 'string')) {
39 return { error: 'argv must be a non-empty list of strings, the CLI first.' }
40 }
41 if (stdin !== undefined && typeof stdin !== 'string') return { error: 'stdin must be a string.' }
42 const cli = argv[0] as string
43 if (!CLIS.includes(cli)) {
44 return { error: `env only runs hosting CLIs, by bare name: ${CLIS.join(', ')}. "${cli}" is not one of them.` }
45 }
46 const keys = keysIn([...argv.slice(1), stdin ?? ''])
47 if (keys.length === 0) {
48 return { error: 'Nothing in argv or stdin names a {{KEY}}, so no env value is needed. Run the command with Bash instead.' }
49 }
50 return { argv: argv as string[], stdin, keys }
51}
52
53export function fill(text: string, values: Record<string, string>): string {
54 return text.replace(PLACEHOLDER, (whole, key: string) => values[key] ?? whole)
55}
56
57// Belt and braces over the scrubber, which skips values under 8 characters:
58// whatever was filled in comes back out of the CLI's output by name
59export function redact(text: string, values: Record<string, string>): string {
60 const known = Object.entries(values)
61 .filter(([, value]) => value.length >= 3)
62 .sort(([, a], [, b]) => b.length - a.length)
63 return known.reduce((out, [key, value]) => out.split(value).join(`[env:${key}]`), text)
64}
65
66// The command as the user is asked to approve it: placeholders, never values
67export function shown(plan: Plan): string {
68 const words = plan.argv.map(arg => (/^[\w@%+=:,./{}-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`))
69 if (plan.stdin === undefined) return words.join(' ')
70 // Spelled out rather than `< ...`, which reads as a file redirect
71 const stdin = /^[^\s"'\\]+$/.test(plan.stdin) ? plan.stdin : JSON.stringify(plan.stdin)
72 return `${words.join(' ')} (stdin: ${stdin})`
73}
74
75export function tail(text: string, max = 1500): string {
76 const trimmed = text.trim()
77 return trimmed.length <= max ? trimmed : `…${trimmed.slice(-max)}`
78}
79hooks/worktree.ts 46 lines1// Env files in this repo's other git worktrees, so mcp__env__copy can seed a
2// new worktree's .env.local from the main checkout. Only the root of a
3// worktree `git worktree list` names counts: anywhere else, a copied value
4// could land where the fence doesn't look.
5
6import { isEnvFile } from './dotenv'
7
8export function parseWorktrees(porcelain: string): string[] {
9 return porcelain
10 .split('\n')
11 .filter(line => line.startsWith('worktree '))
12 .map(line => line.slice('worktree '.length).trim())
13}
14
15// Resolves `.` and `..` without touching the disk
16export function normalize(path: string, base: string): string {
17 const parts: string[] = []
18 for (const part of (path.startsWith('/') ? path : `${base}/${path}`).split('/')) {
19 if (part === '' || part === '.') continue
20 if (part === '..') parts.pop()
21 else parts.push(part)
22 }
23 return `/${parts.join('/')}`
24}
25
26export type Place = { path: string; label: string }
27
28// A bare name is a file in the session's own project root, as before. A path
29// must end in an env file name and sit at the root of a known worktree.
30export function resolve(value: unknown, cwd: string, worktrees: string[]): Place | { error: string } {
31 if (typeof value !== 'string' || value.trim() === '') return { error: 'from and to must name env files.' }
32 const raw = value.trim()
33 if (!raw.includes('/')) {
34 return isEnvFile(raw) ? { path: raw, label: raw } : { error: `"${raw}" is not an env file name.` }
35 }
36 const full = normalize(raw, cwd)
37 const name = full.slice(full.lastIndexOf('/') + 1)
38 const dir = full.slice(0, full.lastIndexOf('/')) || '/'
39 if (!isEnvFile(name)) return { error: `"${raw}" is not an env file.` }
40 if (!worktrees.includes(dir)) {
41 const known = worktrees.length > 0 ? ` Worktrees: ${worktrees.join(', ')}` : ''
42 return { error: `${dir} is not the root of one of this repo's git worktrees, so env won't write there.${known}` }
43 }
44 return { path: full, label: dir === cwd ? name : full }
45}
46types/index.d.ts 29 lines1export type EnvEditing = { file: string; key: string | null }
2export type EnvRequest = {
3 key: string
4 file: string
5 reason: string
6 steps: string[]
7 url?: string
8 format?: string
9 pattern?: string
10}
11export type EnvFit = 'ok' | 'off' | null
12
13declare module 'claude-code' {
14 interface PluginState {
15 env: {
16 files: string[]
17 active: string
18 editing: EnvEditing | null
19 revealed: string | null
20 confirm: string | null
21 request: EnvRequest | null
22 fit: EnvFit
23 rev: number
24 flash: string | null
25 isWaiting: boolean
26 }
27 }
28}
29