poenv

poenv

Your configs and secrets in one encrypted vault on your machine. Switch a repo's .env between environments with one command, and let AI agents use your secrets without poenv ever showing them the values.

cd my-app
poenv init                 # import the env file into the vault
poenv use staging          # the env file is now on staging
poenv add OPENAI_API_KEY   # pull in a key you share across apps
poenv run -- npm test     # inject the env without writing a file
One local vaultA single AES-256-GCM file, locked with your master password. Unlock once a day, the same way on macOS, Linux and Windows. No server, no account.
Whole-folder switchingpoenv use staging rewrites the env file itself, so Next.js, Vite, Prisma, Docker Compose and Python all see the new env without wrappers.
Shared keysSet OPENAI_API_KEY once, reference it from every app, rotate it in one place.
Agent-safeAgents can list key names, switch envs and run commands, but poenv never prints a value to them, and they can't touch prod.

Install

macOS & Linux
curl -fsSL https://poenv.pogi.codes/install.sh | sh
Windows (PowerShell)
irm https://poenv.pogi.codes/install.ps1 | iex

No sudo or admin rights. Installs to ~/.local/bin (Windows: %LOCALAPPDATA%\poenv\bin), checks the SHA-256 and adds that folder to your PATH.

Installer options

install.sh flagEnv var (both installers)Does
--version XPOENV_VERSIONInstall a specific release
--dir PATHPOENV_INSTALL_DIRInstall somewhere else
--no-modify-pathPOENV_NO_MODIFY_PATHLeave your PATH alone
--yesDon't ask anything (for agents and scripts)

Pass flags through sh -s --:

curl -fsSL https://poenv.pogi.codes/install.sh | sh -s -- --version 0.6.1

On Windows, set the env vars before running the installer. You can also download a binary from Releases (macOS, Linux and Windows, x64 and arm64).

Update

Run the installer again. It replaces the binary and leaves your vault, PATH and repos alone. poenv --version shows what you have; --version X pins or rolls back.

Uninstall

  1. poenv lock, so the unlocked key leaves your temp folder.
  2. Delete the binary: rm ~/.local/bin/poenv (Windows: %LOCALAPPDATA%\poenv\bin\poenv.exe).
  3. Remove the line ending in # added by poenv installer from your shell rc file (Windows: remove the folder from your user Path).
  4. Optional and permanent: delete the vault in ~/.poenv (Windows: %APPDATA%\poenv). Run poenv backup first if you might want your secrets back.

Repos keep working: each env file still holds the env it was last switched to. Delete .poenv and the poenv section in AGENTS.md if you like.

Quick start

  1. Check your setup.
    poenv doctor
    It checks PATH, the vault and the current repo, and prints the exact command for anything to fix.
  2. Set up a repo.
    cd your-repo
    poenv init
    The first time on a machine, init asks you to choose a master password, then shows a 24-word recovery phrase, once. Save the phrase in your password manager: it's the only way back in if you forget the password, and it opens your backups.

    init then imports your existing env file into the local env, creates staging and prod (protected), writes .poenv, gitignores it and the env file, and adds poenv notes to AGENTS.md or CLAUDE.md.

  3. Fill in another env and switch to it.
    poenv set DATABASE_URL --env staging   # asks for the value, hidden
    poenv use staging
    Or paste KEY=VALUE lines into the env file and run poenv import --env staging, or run poenv on its own to edit everything in the TUI.
poenv init: choose a password, save the recovery phrase, import .env.local into the vault

Concepts

TermMeans
VaultOne encrypted file on your machine holding every app, env and key. App names and key names are encrypted too, not only values.
AppOne per repo, e.g. my-app. Named in the repo's .poenv file.
EnvA set of keys for an app: local, staging, prod, or any name you add. An env can be protected.
KeyA name and a value. Every change keeps the previous value: the last 10 versions per key.
Shared keyA key that belongs to no app, like OPENAI_API_KEY. Apps hold a live reference to it, optionally under another name.
Managed blockThe section at the top of the env file that poenv writes. Everything outside it is yours.

Switching envs

poenv use staging
✓ my-app: local → staging  (.env.local)
  ~ DATABASE_URL
  ~ STRIPE_SECRET_KEY
  + SENTRY_DSN

poenv rewrites the managed block of the repo's env file and lists what changed, by key name only. With no argument, poenv use opens a picker. Running apps need a restart unless they reload .env themselves.

poenv use staging: .env.local switches from local to staging

Manage envs

poenv env new preview --from staging   # copy an env (or omit --from for an empty one)
poenv env rename preview qa            # the env file and .poenv follow
poenv ls                               # apps, envs and key names

Show the env in your prompt

poenv status --short prints my-app:staging. It never opens the vault, so it's instant and safe to run on every prompt.

Shared keys

Keys you use in many apps, like an API key, belong in the shared pool. Set it once, then reference it from each app:

poenv set OPENAI_API_KEY --shared              # once
poenv add OPENAI_API_KEY                       # in each repo: a live reference
poenv add OPENAI_API_KEY --as NEXT_PUBLIC_OPENAI_KEY   # under another name

Rotating it with poenv set OPENAI_API_KEY --shared rewrites the env file of every repo on this machine that uses it, and lists those files.

To copy a value once instead, with no link, take it from another app: poenv add STRIPE_KEY --from other-app/staging.

When init finds a key with the same name and value in another app, it offers to move it into the shared pool and turn each copy into a reference.

Shared keys: blog shares OPENAI_API_KEY with shop, then one rotation updates both env files

Removing a shared key

poenv never leaves dangling references. If apps still use the key, choose:

  • poenv rm KEY --shared --keep-copies: each app keeps its own copy, with history. This is how you undo sharing.
  • poenv rm KEY --shared --everywhere: the references go too.

Protected envs

prod is protected from the start. Switching to a protected env, running with it, or copying or renaming it asks a person to type a confirmation. Agents and scripts are refused with exit 4, and --yes doesn't skip it. Toggle protection on any env with P in the TUI.

Running without a file

poenv run -- npm test                 # the current env
poenv run staging -- npm run migrate  # another env

poenv run injects the env as variables into one command and leaves the env file alone. Nothing is written to disk and no quoting applies, so use it for tools that don't read .env, for values a loader can't parse, or when you'd rather not have secrets in a file at all. It passes through the command's output and exit code. Anything after --, including --json, belongs to your command.

poenv run: staging and prod are injected into single commands while .env.local stays on local

Frameworks & formats

poenv works with anything that reads a .env-style file. init picks the file and quoting format from the repo:

RepoFileFormat
Next.js (next in package.json).env.localnext
Python (pyproject.toml, requirements.txt or Pipfile).envpython
Anything else: Vite, Remix, Express, Bun, Docker Compose….envdotenv

If the repo's env files already hold keys, init uses the file that has them (and asks when several do). Override with --file and --format, or edit .poenv.

The format only matters for values containing $ or escapes, which loaders read differently. Each format is checked in CI against the real Node dotenv, Next.js and python-dotenv loaders.

Vite and Bun expand $VAR in env files, so they'd read the dotenv format's unescaped $ as a variable. Use --format next there, which escapes it as \$.

A few values can't be written exactly in some formats (for example ${…} for python). poenv writes them as best it can, lists the keys, and suggests poenv run.

Java & Spring Boot

Spring Boot doesn't read .env files, so poenv use alone changes nothing for it. Inject the env with poenv run:

poenv run -- ./mvnw spring-boot:run
poenv run staging -- ./gradlew bootRun

Spring reads environment variables natively: reference them in application.properties (spring.datasource.password=${DB_PASSWORD}), or name keys for relaxed binding (SPRING_DATASOURCE_URL sets spring.datasource.url).

With Docker Compose, env_file: .env reads the file poenv writes. Avoid spring.config.import=optional:file:.env[.properties]: properties files don't strip quotes, so any value poenv quotes keeps its quotes.

The TUI

Run poenv on its own in a terminal to browse and edit apps, envs and keys. Values stay masked until you press v. Inside a repo it opens on that repo's app. Lowercase keys act on the selected key, uppercase on the whole env.

The TUI: reveal a value, flip between envs, press u to switch .env.local to staging
Key actions
aAdd a key
rAdd a reference to a shared key (optionally renamed)
e / ⏎Edit the value
dDelete the key
sMove the key into the shared pool (keeps its history)
vShow / hide the value
Env actions
uUse this env: writes the env file, like poenv use
ANew env
RRename env
PToggle protected
DDelete env
Navigation
← → / tabPrevious / next env
↑ ↓Previous / next key (vim's hjkl work too)
esc / backspaceBack to the app list
iSet up this repo (shown when it isn't yet)
qQuit

Locking & CI

poenv stays unlocked for 12 hours at a time, across every terminal on the machine. When it's locked, commands and the TUI ask for your password.

poenv unlock --for 8h   # any duration: 30m, 8h, 2d
poenv lock              # close it early
poenv status            # shows the lock state

Agents can't type the password, so while locked they get exit 5 and ask you to run poenv unlock.

On CI and servers, pipe the password in from your secret store:

printf '%s' "$VAULT_PASSWORD" | poenv unlock --stdin

Set POENV_HOME to keep the vault somewhere other than the default folder.

Backup & recovery

poenv backup backup.enc    # encrypted copy of the vault
poenv restore backup.enc   # restore it, on this machine or another

A backup plus your recovery phrase restores everything on any machine: on a new machine, restore asks for the phrase, then for a password for that machine. Restoring over an existing vault asks first and keeps the old one as store.enc.bak-<time>.

SituationRun
Forgot the master passwordpoenv recover: type the phrase, choose a new password
Check the phrase you saved is rightpoenv recover --check
Phrase may have leakedpoenv recover --new-phrase (older backups still need the old phrase)
Change the passwordpoenv passwd
Undo a bad change to a keypoenv history KEY, then poenv rollback KEY [N]

Lose both the password and the phrase, and the vault can't be opened. Nobody can reset it for you.


Commands

Every command takes --json for machine-readable output, except poenv run. Values are never accepted as arguments: set asks for them hidden, or reads them from stdin.

Daily

CommandDoes
poenvOpen the TUI
poenv use [ENV]Switch this repo's env file to ENV (picker if omitted)
poenv add KEY [--as NAME] [--from APP/ENV] [--env ENV]Add a shared key (live reference) or copy one from another app
poenv status [--short]Current app, env and lock state (--short → my-app:staging)
poenv run [ENV] -- CMDRun CMD with ENV injected, without touching the file

Setup & management

CommandDoes
poenv init [--app NAME] [--env ENV] [--file FILE] [--format F] [--no-import] [--yes]Set up a repo: import the env file, write .poenv, gitignore, agent notes
poenv set KEY [--shared] [--env ENV] [--empty]Set a value (asked for hidden, or piped in). Empty input is an error unless --empty
poenv import [--env ENV]Save keys you pasted into the env file to the current env (or ENV), overwriting existing ones
poenv rm KEY [--shared [--keep-copies | --everywhere]] [--env ENV]Remove a key (see shared keys)
poenv env new NAME [--from ENV]Add an env, empty or a copy of ENV
poenv env rename OLD NEWRename an env; the env file and .poenv follow
poenv file [NAME]Show or change the env file poenv manages; the block moves with it
poenv ls [APP]Apps, envs and key names (never values)
poenv unlock [--for 8h] [--stdin]Unlock with your password (12h by default)
poenv lockLock now
poenv doctor [--fix]Check your setup; --fix applies the safe fixes

Just for you

These reveal values or touch the whole vault, so they're refused for agents and scripts (exit 4).

CommandDoes
poenv get KEY [--shared] [--env ENV]Print a value
poenv history KEY [--show]Previous values (last 10, masked unless --show)
poenv rollback KEY [N]Restore a previous value
poenv backup [FILE] [--force]Encrypted backup
poenv restore FILERestore a backup
poenv passwdChange your master password
poenv recover [--check | --new-phrase]Use your phrase / check it / make a new one

history, rollback and rm also take --shared and --env ENV.

Exit codes

Stable, so scripts and agents can act on them. Errors always say the command that fixes them.

CodeMeansWhat to do
0OK
1ErrorRead the message; try poenv doctor
2Not set up (no .poenv or no vault)poenv init
3Unknown app, env or keypoenv ls to see what exists
4Refused: human-only command or protected envA person runs it in their own terminal
5Waiting on the user (locked, or the vault needs creating)A person runs poenv unlock or poenv init

The env file

poenv owns one block at the top of the env file and never touches anything else (except during init and import, which move the keys they import into the block):

# >>> poenv >>> (managed: edits here are overwritten)
# app: my-app  env: staging
DATABASE_URL=postgres://staging-db/app
OPENAI_API_KEY='sk-…'
# <<< poenv <<<

DEBUG=true   # yours: kept as-is, and it wins over the block

Because the block is at the top, your own lines below it override it in every loader. A key set in both places is reported as a conflict.

A change made anywhere (set, add, rm, rollback, or the TUI) also rewrites the block in every other repo on this machine whose current env it affects. Each repo stays on its own env.

The .poenv file

Written by init at the repo root. It holds names only, never secrets.

app    = "my-app"
env    = "local"        # the env the file is on
file   = ".env.local"   # the env file poenv writes
format = "next"         # dotenv | next | python

It's gitignored by default. You can commit it instead: a teammate's poenv init in a fresh clone then adopts its settings, and poenv leaves a tracked .poenv alone.

Files & settings

PathHolds
~/.poenv/store.enc
Windows: %APPDATA%\poenv\store.enc
The vault, encrypted as a single blob
keys.json (next to the vault)The master key, encrypted with your password
repos.json (next to the vault)Paths of repos that use poenv, so changes reach them. No values; not in backups
Per-user temp folderThe unlock session (owner-only), kept out of the vault folder so backups and sync never copy it
Env varDoes
POENV_HOMEFolder for the vault and its files
POENV_DEBUGPrint stack traces on errors

For AI agents

Agents can do the daily work: check status, list key names, switch envs and run commands with secrets injected. poenv never prints a value to them, never accepts one as an argument, and keeps values out of --json.

poenv status --json              # which app/env this repo is on
poenv ls --json                  # envs and key names, never values
poenv use staging --json         # switch the env file
poenv run staging -- npm test    # run with an env injected
poenv doctor --json              # when something is off: follow each "fix"

Agents are refused (exit 4) for get, history, rollback, backup, restore, recover and protected envs, and can't create the vault (exit 5), since that shows the recovery phrase.

In an agent shell: poenv ls works, poenv get and poenv use prod are refused, poenv use staging works

poenv init adds a short section to AGENTS.md / CLAUDE.md telling agents these rules. To have an agent install poenv, point it at llms.txt or the agent install guide, or say:

Install poenv (https://poenv.pogi.codes/llms.txt) and set up this repo.

The /poenv skill

Install the poenv agent skill once and every coding agent on your machine knows these rules in any repo: Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode and more.

npx skills add pogicodes/poenv

Then type /poenv setup or /poenv use staging ($poenv in Codex). Agents also reach for it on their own when you ask about env vars.

How poenv tells people from agents

A person has an interactive terminal on stdin and stdout and none of the markers coding agents set (CLAUDECODE, CODEX_SANDBOX, CURSOR_AGENT, GEMINI_CLI, AI_AGENT, …). A CI variable counts as a script too.

Security model

  • While locked, nothing can read your secrets: not other programs, not agents. The master key is stored only encrypted with your password (scrypt).
  • While unlocked, the master key sits in an owner-only session file until the session expires. Programs running as you can read it then, which is what lets agents work. poenv lock ends it early.
  • Encryption protects the vault file against being copied: backups, sync folders, a stolen disk. It is not a sandbox against programs running as you.
  • Values are never printed by agent-usable commands, never accepted as arguments (so they stay out of shell history), and never in --json output.
  • Once a value is written to an env file, anything that can read that file can read it, and a command run with secrets injected can print them. poenv run avoids writing files at all.

These rules keep secrets out of poenv's own output. They are not a sandbox: an agent that can read your files can read a written .env.

Troubleshooting

Start with poenv doctor. It checks PATH, the vault, the lock, your saved phrase, gitignore, agent notes, dangling shared references and whether the env file is up to date, and names the fix for each. poenv doctor --fix applies the safe ones; it never runs sudo.

poenv: command not found after installing

Open a new terminal so the PATH change applies, or use the full path the installer printed.

My app still sees the old values

Restart it: most dev servers read .env only at startup. Also check you don't set the same key below the poenv block, since your lines win.

A value with $ comes out wrong

Your loader expands variables. Switch the format (--format next escapes $), or use poenv run. See Frameworks & formats.

An agent keeps getting exit 5

poenv is locked, or the vault doesn't exist yet. Run poenv unlock (or the first poenv init) in your own terminal. Never give the agent your password.