SLOPSHOPPER

env

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

newpanebandguardcommandtoast
★ 3v0.4.0MITupdated 2026-10-05davekiss/env
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · env
│ ┃ env ✕ › fix the failing auth test and add an audit log call │ ┃ + .env.local │ ┃ ⏺ Read(src/auth.ts) │ ┃ No .env files in the project root yet. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(cat .env) │ ⎿ Denied by env: env: .env files are edited by the user in │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /env │ ⎿ env: Opened the env pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · env
+ .env.local No .env files in the project root yet.
README

<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                                         │
╰──────────────────────────────────────────────────────╯

Get started

/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.

What it's good at

It asks the way a teammate would

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."

Claude never sees a value

Two layers keep values out of the conversation:

  1. The fence. Claude's Read, Edit, Write and Grep can't open .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.
  2. The scrubber. Any value in your env files that's 8 characters or longer is replaced with [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.

It still does the busywork

Claude can manage your env files without reading them:

ToolWhat it does
mcp__env__listLists keys and what each value looks like (kind, length, a known prefix like sk_live_), never the value
mcp__env__requestShows you the card above
mcp__env__generateWrites a random secret (auth secrets, signing keys) without returning it
mcp__env__copyCopies a value, or a whole file, to another env file or into another git worktree, without reading it
mcp__env__removeComments a key out, so you can bring it back
mcp__env__pushRuns a hosting CLI (vercel, gh, wrangler, fly…) with {{KEY}} filled in from your env file, after you approve the command
mcp__env__pullRuns 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."

It talks to your host

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."

It edits the file the way you wrote it

/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.

It fits a narrow terminal

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.

Limits

  • The paste field isn't masked, so a value is visible on your screen until you press Enter.
  • Values shorter than 8 characters, booleans and numbers aren't scrubbed.
  • Only the project root is scanned. Monorepo subfolders aren't covered yet.
  • Don't paste a secret into the chat box. A value only gets scrubbed once it's saved in an env file.
  • The pane draws in the terminal and the desktop app's Code tab. In the VS Code extension and claude -p, the fence, scrubber and tools still work, but there's no pane.

Development

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.

License

MIT

Source 7 files
hooks/register.tsx 901 lines
1import { 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}
901
hooks/dotenv.ts 189 lines
1// 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}
189
hooks/guard.ts 57 lines
1import { 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}
57
hooks/pull.ts 77 lines
1// 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}
77
hooks/push.ts 79 lines
1// 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}
79
hooks/worktree.ts 46 lines
1// 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}
46
types/index.d.ts 29 lines
1export 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