Directory-scoped environment variables. Like direnv, except the config lives outside the repo, so there is no .envrc to accidentally commit, and secret values go to the operating system's secret store instead of to disk.
cd ~/dev/threa
echo $CLAUDE_CODE_OAUTH_TOKEN # sk-ant-oat01-...-work
cd apps
echo $CLAUDE_CODE_OAUTH_TOKEN # sk-ant-oat01-...-work (inherited)
cd ~/dev
echo $CLAUDE_CODE_OAUTH_TOKEN # (ejected on leave)macOS and Linux are supported. macOS stores secrets in the login Keychain. Linux uses the desktop Secret Service through libsecret; GNOME Keyring, KWallet, or KeePassXC's Secret Service integration must be available in your session.
case "$(uname -s)-$(uname -m)" in
Darwin-arm64) ASSET=slopenv-darwin-arm64 ;;
Darwin-x86_64) ASSET=slopenv-darwin-x64 ;;
Linux-aarch64|Linux-arm64) ASSET=slopenv-linux-arm64 ;;
Linux-x86_64) ASSET=slopenv-linux-x64 ;;
*) echo "unsupported platform: $(uname -s)-$(uname -m)" >&2; exit 1 ;;
esac
curl -fsSL "https://github.com/kristofferremback/slopenv/releases/latest/download/$ASSET.tar.gz" | tar xz
mv "$ASSET" ~/.local/bin/slopenv # or anywhere on your PATHOn Linux, install a Secret Service implementation before storing secrets. For example, on Debian or Ubuntu with GNOME or XFCE:
sudo apt install gnome-keyring
# Log out and back in so the login keyring starts and unlocks with the session.KDE users can use KWallet, and KeePassXC can provide the same API when Secret Service Integration is enabled.
Requires Bun.
git clone https://github.com/kristofferremback/slopenv.git && cd slopenv
bun install
bun run build # produces ./slopenv
ln -sf "$PWD/slopenv" ~/.local/bin/slopenv # symlink, so rebuilds are liveAdd one line to ~/.zshrc:
eval "$(slopenv hook zsh)"Open a new shell, then check everything is wired up:
slopenv doctorslopenv doctor is the thing to run when a rule exists but nothing is being injected. Installing the binary and adding the hook line are two separate steps, and doing only the first leaves slopenv list showing rules that never take effect.
That one line also sets up tab completion. See Completion.
# Non-secrets go in the rules file, in plain text.
slopenv set NODE_ENV=development ./
slopenv set NODE_ENV ./ # prompts, echo on
# --secret puts the value in the keychain instead. Prompts with echo off, so it
# never reaches shell history.
slopenv set --secret CLAUDE_CODE_OAUTH_TOKEN ./ --alias "Claude Code for work"
# Or inline, if you don't mind it in your history.
slopenv set --secret GITHUB_TOKEN=ghp_xxxxxxxx ~/dev/oss
# Or from a pipe, which keeps it out of history and out of argv.
cat token.txt | slopenv set --secret CLAUDE_CODE_OAUTH_TOKEN ./
# Apply a value you already have to a second directory, without copying it.
slopenv link CLAUDE_CODE_OAUTH_TOKEN --from ~/dev/threa
# Or take it from 1Password, and keep the reference rather than the value.
slopenv pull CLAUDE_CODE_OAUTH_TOKEN --ref "op://Work/Claude Code/credential"
slopenv pull --all # re-fetch every reference, e.g. on a new machine
# Turn it off in this terminal for a while, without changing any rule.
slopenv off
slopenv on
slopenv list # every rule; secret values shown as •••1234
slopenv status # what applies right here, and which rule won
slopenv rm CLAUDE_CODE_OAUTH_TOKEN ./
slopenv edit # open rules.json in $EDITOR
slopenv doctor # check hook, rules file, and keychainslopenv list:
DIRECTORY VARIABLE SOURCE VALUE ALIAS
~/dev/oss CLAUDE_CODE_OAUTH_TOKEN keychain •••pers Claude Code personal
~/dev/threa CLAUDE_CODE_OAUTH_TOKEN keychain •••work Claude Code for work
~/dev/threa NODE_ENV plain development
~/dev/threa/apps PORT plain 3000
A second repo that belongs to the same project usually wants the same token. link gives it that token without giving it a second copy:
cd ~/dev/threa-web
slopenv link CLAUDE_CODE_OAUTH_TOKEN --from ~/dev/threaThe rule that lands in ~/dev/threa-web holds no value of its own. It borrows the one in ~/dev/threa, so rotating the token there rotates it everywhere it is linked, and a secret still exists exactly once in your keychain.
DIRECTORY VARIABLE SOURCE BORROWS FROM VALUE ALIAS
~/dev/threa CLAUDE_CODE_OAUTH_TOKEN keychain •••work Claude Code for work
~/dev/threa-web CLAUDE_CODE_OAUTH_TOKEN link ~/dev/threa •••work Claude Code for work
--from takes any directory the source rule covers, not only the directory it is registered for, so --from ~/dev/threa/apps finds the rule at ~/dev/threa and records that. A trailing DIR argument works the way it does on set: slopenv link TOKEN --from ~/dev/threa ./packages/api links a subdirectory instead of the current one.
Links never chain. Linking to something that is itself a link resolves to the real rule at the moment you create it, which is also why a cycle cannot be built. A link is one hop, always.
Because a link is only meaningful next to the value it borrows, removing that value is refused rather than silently breaking it:
$ slopenv rm CLAUDE_CODE_OAUTH_TOKEN ~/dev/threa
slopenv: 1 rule links to CLAUDE_CODE_OAUTH_TOKEN in /Users/you/dev/threa:
/Users/you/dev/threa-web
Remove them first, or remove all of them together with: slopenv rm CLAUDE_CODE_OAUTH_TOKEN /Users/you/dev/threa --force
Removing the link itself (slopenv rm CLAUDE_CODE_OAUTH_TOKEN ~/dev/threa-web) never touches the value or the keychain. Giving a linked directory its own value with set replaces the link, and says so.
slopenv pull fetches a value from an external secret manager and caches it in the keychain. What lands in rules.json is the reference, never the value:
$ slopenv pull CLAUDE_CODE_OAUTH_TOKEN --ref "op://Work/Claude Code/credential" --alias "Claude Code for Work"
CLAUDE_CODE_OAUTH_TOKEN = •••awAA [vault] /Users/you/dev/telness Claude Code for Work
pulled from op://Work/Claude Code/credential
slopenv pull CLAUDE_CODE_OAUTH_TOKEN # re-fetch; the reference is already known
slopenv pull --all # re-fetch every one of themIn 1Password, the item's ⌄ menu has Copy Secret Reference, which gives you exactly the op:// string this wants. You need the 1Password CLI (brew install 1password-cli) and the desktop app integration turned on under Settings → Developer.
The vault is never consulted on a cd. Measured against op 2.35 on an M3 Pro:
first op read in a terminal session |
6137 ms (a fingerprint is in there) |
every op read after it |
~1170 ms — a network round trip, and it stays one |
slopenv export, activating that same value |
42 ms |
a cd inside the directory, after the pull |
no op at all |
None of that belongs on something that runs every time you change directory. Once pulled, a vault rule is read out of the keychain exactly like any other secret, offline. Only slopenv pull talks to 1Password, and only because you typed it.
1Password authorises per terminal session — terminal identity plus start time, expiring after 10 minutes of inactivity and whenever the app locks. So a whole slopenv pull --all costs one approval, not one per secret, and a second pull in the same terminal usually costs none.
That means a cached value can go out of date, which is what --ttl is for:
slopenv pull GITHUB_TOKEN --ref "op://Work/GitHub/token" --ttl 30dPast that window, entering the directory still exports the cached value and adds one line on stderr saying it is overdue and how to refresh it. It never blocks your prompt on the network: an old token plus a warning beats both a frozen terminal and a variable that is silently unset.
An email address, a username, an account ID — putting those in the keychain is overkill, and list showing you •••.com is worse than useless. --plain keeps the value in the rules file instead:
$ slopenv pull NOTION_USER --ref "op://Employee/Notion/Username" --plain
NOTION_USER = kristoffer@example.com [vault] /Users/you/dev/telness
pulled from op://Employee/Notion/Username
kept in /Users/you/.slopenv/rules.json, in the clear
It is still a vault rule — pull --all refreshes it with everything else, and the reference stays the source of truth. The only difference is where the value ends up, and it is shown in full everywhere, because masking a value that is sitting in a file in the clear would only pretend to a secrecy it does not have.
The keychain remains the default, because a value coming out of a secret manager is a secret until you say otherwise. --plain runs the same credential check set does and refuses anything that looks like a token:
$ slopenv pull GITHUB_TOKEN --ref "op://Work/GitHub/token" --plain
slopenv: GITHUB_TOKEN: that looks like a credential, going by its name.
--plain writes it to /Users/you/.slopenv/rules.json in plain text.
Without --plain it goes to the keychain instead, and `list` shows only the last four characters.
Write it to the rules file anyway? [y/N]
--secret moves one back, and takes the plain-text copy with it. Moving in the other direction deletes the keychain entry, so a value only ever exists in one place.
There is no way to store a command for slopenv to run. The rule holds an engine name and the vault's own reference string, and slopenv builds the argument list itself from a table in the source — no shell, no interpolation, and the reference is passed as a single argv element however many quotes and dollar signs it contains. A rules file cannot ask slopenv to execute something of its choosing.
rules.json stops containing secrets and starts describing where they live, so it becomes something you can keep in your dotfiles. A new machine is then:
slopenv pull --all--all overlaps its reads four at a time, because ~1.17 s of network latency each is the whole cost and waiting for them one after another is the only avoidable part. The first read goes alone: it is the one that may raise the approval prompt, and four of those at once would be a race over a single dialog. Nine references take about a third of what they would sequentially.
One failure does not stop the rest — finishing is the point — but the exit code is non-zero and the commands to retry are printed.
link works on top of a reference, so one pull rotates every directory that borrows it. rm takes the cached value with it. doctor reports each reference with its cache state and age, and calls a missing cache a failure, because that is the thing that actually breaks.
Sometimes you want the repo's variables out of the way — running something against a different token, or checking what a tool does with nothing set. slopenv off unloads them in the terminal you are standing in:
$ slopenv off
slopenv: off in this shell — unloaded CLAUDE_CODE_OAUTH_TOKEN, NODE_ENV.
back on when you leave ~/dev/threa, or now with: slopenv on
Nothing is written and no rule changes. Other terminals are unaffected, and so is the next one you open: the pause lives in $SLOPENV_STATE, so it is session-scoped by construction rather than by policy.
It ends in one of two ways. slopenv on ends it where you stand. Otherwise walking out of the directory ends it, and says so rather than changing things behind your back:
$ cd ~/dev
slopenv: env vars are on again — the pause ended when you left ~/dev/threa.
The pause is pinned to the rule directory that owns the variables, not to $PWD, so moving around inside the project keeps it off — including into subdirectories with rules of their own. Where several rules apply, it pins to the deepest, which biases towards ending too early rather than too late: ending early is visible and one word to undo, while ending late means standing in an unrelated repo with variables missing and nothing to connect that to something you did an hour ago.
Whatever your shell had before slopenv touched a variable comes back for the duration, exactly as it does when you leave the directory. slopenv status and slopenv doctor both report a pause, since it is otherwise invisible and it is precisely the state in which you go looking.
off and on have to change the shell that ran them, so they work through a slopenv shell function that evals their output; eval "$(slopenv hook zsh)" installs it along with everything else. If it is missing — an older hook still loaded in a long-lived terminal — they say so instead of printing shell statements at you.
slopenv update # download the latest release and replace the binary
slopenv update --check # report only, change nothingThe download is verified against the release's SHA256SUMS before it is unpacked, and the new binary is run and asked its version before the old one is overwritten. If either check fails, the binary you have keeps working. The swap itself is a rename inside the target's own directory, so there is never a half-written binary on your PATH.
It refuses rather than guessing when the install is not its to replace: running from source under Bun, a build sitting inside a clone of this repo, or a directory you cannot write to. Each case says what to do instead.
eval "$(slopenv hook zsh)" installs completion along with the hook, so there is nothing else to add. slopenv completions zsh prints it on its own if you would rather keep the two separate, and slopenv completions bash does the same for bash.
It completes your own rules, not just the command names:
$ slopenv rm <TAB>
FULL_NAME NODE_ENV PORT # the variables you have registered
$ slopenv rm PO<TAB>
$ slopenv rm PORT
$ slopenv set NODE_ENV <TAB>
~/dev/threa ~/dev/threa/apps # directories those rules cover, then any directory
$ slopenv link TOKEN --from <TAB>
~/dev/threa ~/dev/threa/apps # the same list, for the directory to borrow from
The candidates come from slopenv list --names and slopenv list --dirs, which print one item per line and never read the keychain, so they are cheap enough to sit behind a TAB press. Both are also useful on their own for scripting.
| Form | Meaning |
|---|---|
slopenv set NAME=VALUE [DIR] |
set the value inline, in the rules file |
--secret on set |
put the value in the keychain instead |
slopenv set NAME [DIR] |
prompt for the value |
slopenv set NAME VALUE DIR |
three-positional form, also accepted |
slopenv link NAME --from SRCDIR [DIR] |
borrow the value that is already registered in SRCDIR |
slopenv pull NAME --ref REF [DIR] |
fetch from a vault and cache it; --ttl 30d sets a refresh window |
--plain / --secret on pull |
keep the value in the rules file rather than the keychain, or move it back |
--dir DIR / --value VALUE |
for anything that would otherwise be misread |
--yes / -y (or --force / -f) |
skip the credential confirmation described below |
--force / -f on rm |
also remove the rules that link to the one being removed |
--names / --dirs on list |
one variable name or rule directory per line |
slopenv off / slopenv on |
unload and reload in the current shell |
With a bare NAME, a second positional is always a directory. DIR defaults to the current directory. Trailing newlines are trimmed from prompted and piped values.
Quoting is your shell's job, and both obvious ways of doing it work. By the time slopenv sees them they are the same single argument:
slopenv set "FULL_NAME=Kristoffer Remback"
slopenv set FULL_NAME="Kristoffer Remback"
slopenv set FULL_NAME --value "Kristoffer Remback"Forget the quotes and it fails, and says why:
$ slopenv set FULL_NAME=Kristoffer Remback
slopenv: "Remback" is not a directory.
If the value has spaces in it, quote it. Either way works:
slopenv set "FULL_NAME=Kristoffer Remback"
slopenv set FULL_NAME="Kristoffer Remback"
Or pass it separately: slopenv set FULL_NAME --value "Kristoffer Remback"
An argument that looks like a path (./typo, /some/where) gets a plain "directory does not exist" instead, since it was clearly meant as a path.
Quote characters that reach slopenv are kept, never stripped. slopenv set 'JSON={"a": "b"}' stores {"a": "b"} verbatim.
set writes to a plain-text file, so it checks whether you are about to put a credential in one and asks first:
$ slopenv set OPENAI_API_KEY=sk-proj-AbCdEf1234567890abcdefghij
slopenv: OPENAI_API_KEY: that value looks like an OpenAI project key.
`set` writes it to ~/.slopenv/rules.json in plain text.
To put it in the keychain instead: slopenv set --secret OPENAI_API_KEY /Users/you/dev/app
Store it in plain text anyway? [y/N]
The default is no. --yes / -y (or --force / -f) skips the question. When there is nobody to ask, in a script or with stdin piped, it refuses instead of hanging on a prompt you cannot see. Pass --yes if you mean it.
Two things trigger it. One is the value's shape: around 30 known credential formats, including sk-ant-, sk-proj-, ghp_, glpat-, xoxb-, AKIA, AIza, SG., npm_, hf_, gsk_, a JWT, a PEM private key block, and a URL with an embedded password. That fires whatever the variable is called. The other is the variable's name: *_SECRET, *_PASSWORD, *_TOKEN, *_API_KEY, *_PRIVATE_KEY, CLIENT_SECRET and friends, unless the value is plainly harmless (a path, a number, a boolean, prose, anything under 8 characters).
It is deliberately quiet. TOKEN_TTL=3600, SSH_KEY_PATH=~/.ssh/id_ed25519, AUTH_URL=https://…, PUBLIC_KEY=ssh-ed25519 … and DATABASE_URL=postgres://host/db all pass without comment. A check that fires too often gets dismissed unread, and then it protects nothing.
slopenv doctor runs the same check over the whole rules file, which catches anything that got in through slopenv edit or a hand edit.
A rule covers its directory and everything beneath it. Matching is on whole path segments, so ~/dev/threa never matches ~/dev/threa-2. Directories are stored symlink-resolved, so entering through a symlink still matches.
When two rules define the same variable, the deeper directory wins:
~/dev/threa TOKEN=work -> in ~/dev/threa/apps you get "apps"
~/dev/threa/apps TOKEN=apps and "work" again when you cd back up
On leaving, a variable is unset. If your shell already had a value for it before slopenv touched it, that value is restored instead.
A link takes part in this like any other rule. It is matched by its own directory and can be shadowed by a deeper rule in the usual way; only the value comes from somewhere else.
| What | Where | Notes |
|---|---|---|
| Rules (directories, variable names, aliases, links, vault references) | ~/.slopenv/rules.json |
mode 0600, in a 0700 directory. Override the whole path with $SLOPENV_CONFIG. |
Non-secret values (slopenv set) |
the same file, in plain text | This is what set means. Use set --secret for anything you care about. |
Secret values (slopenv set --secret) |
macOS Keychain or Linux Secret Service | Service slopenv, account <dir>::<VAR_NAME>. Never written to disk by slopenv. |
| Per-shell state | $SLOPENV_STATE in your environment |
Base64 JSON. No temp files, nothing shared between shells. |
Nothing is ever written inside your project. There is no .envrc equivalent, so there is nothing to .gitignore and nothing to leak in a commit.
rules.json carries a version, and the file only claims the version it actually needs: 2 once it contains a link, 3 once it contains a vault reference, 4 once one of those keeps its value in the file, 1 otherwise. A file without either stays readable by an older slopenv; one with them tells an older build to update rather than complaining about a rule shape it does not know.
Secret values never touch disk in plaintext, never appear in rules.json, and never appear in slopenv list, status, doctor or --json output. Those show ••• plus the last four characters, enough to tell two tokens apart and not enough to use one.
slopenv set --secret NAME ./ prompts with echo off, and piping (cat token.txt | slopenv set --secret NAME ./) also keeps the value out of history. The inline NAME=VALUE form does not, which is the trade-off you make by using it.
On macOS, slopenv writes to the keychain by feeding commands to security -i on stdin, so the secret does not appear in any process's argument list. The exception is a value containing a literal newline: security -i is line-based, so those fall back to passing the value as an argument, where ps could see it for a few milliseconds. macOS only shows argument lists to the same user, and to root.
On Linux, slopenv calls Bun.secrets in-process. Bun talks to Secret Service through libsecret, so there is no helper-process argument list.
To restore what your shell had before slopenv touched a variable, that previous value has to be remembered somewhere, and it lives in $SLOPENV_STATE. It is per-shell and never written to disk, but it is in your environment, as is the injected variable itself. Anything that can read your environment can read your injected secrets. The same is true of direnv and of plain export.
On macOS, slopenv shells out to /usr/bin/security rather than using an in-process keychain API. This was measured rather than assumed. macOS binds a keychain item's ACL to the creating binary's code signature, and a self-compiled slopenv is ad-hoc signed, so its identity changes on every build.
Writing a secret with Bun.secrets from one build and reading it from the next produces errSecUserCanceled (-128), which is a modal permission dialog. Running the same code with bun run instead of the compiled binary hangs on that dialog indefinitely. For something invoked on cd, that means a popup blocking your shell after every bun run build. /usr/bin/security is Apple-signed and stable, so slopenv's entries stay readable across rebuilds without prompting.
Linux has no per-binary Secret Service ACL, so it does not have that rebuild problem. The Linux backend uses Bun.secrets, which calls libsecret asynchronously and works with any org.freedesktop.secrets provider. If no provider is running, slopenv fails with setup advice rather than falling back to plaintext.
If slopenv were ever signed with a stable Developer ID, the in-process API would become the better choice on macOS too.
Windows has no backend yet. Secret operations fail rather than falling back to plaintext.
A child process cannot change its parent shell's environment, so slopenv is two pieces, direnv-style:
- The CLI manages
rules.jsonand keychain entries. - The shell hook runs
slopenv export "$PWD"oncdandevals its output, a series ofexportandunsetstatements. It also defines aslopenvshell function, so thatoffandon— the two commands whose job is to change the calling shell — get the same treatment. Everything else passes through it untouched.
On each run, slopenv works out what should be active for $PWD, diffs it against what $SLOPENV_STATE says is active, and emits only the difference. The keychain is read when a variable newly activates, rather than on every cd.
The generated zsh hook does not call slopenv on most cds at all. What slopenv would export is a pure function of (rules file, $PWD), so the hook keeps a fingerprint of the rules file and the list of rule directories in shell variables and checks both in pure zsh. zstat is a builtin and ${PWD:P} resolves symlinks in-process, so nothing forks:
cd work; cd apps; cd apps; cd work -> 4 slopenv invocations
cd apps; cd deep; deeper; deepest; ..; .. -> 2 slopenv invocations
The fingerprint is inode:mtime:size. The inode does the real work, since every write lands via rename(2) onto a fresh temp file, so any change to the rules file changes its inode. That is also what makes a slopenv set in one terminal show up in another: the hook is registered on precmd as well as chpwd, so the next prompt picks it up without a cd.
If you would rather have no shell-side caching, slopenv hook zsh --simple prints a hook that calls slopenv on every cd.
Measured on an Apple M3 Pro (macOS 14.7):
slopenv export, nothing to activate |
~16 ms |
slopenv export, activating a keychain secret |
~31 ms |
a cd the fast path skips |
no process spawned |
Two terminals running slopenv set at the same instant would each read the old rules file, and the second write would silently erase the first one's rule. So every read-modify-write happens under an O_EXCL lock file (with owner-PID and stale-lock detection), and the write itself is temp file, fsync, rename. Readers never lock and never see a torn file.
The test suite runs 16 concurrent writers and checks that all 16 rules survive.
bun test
SLOPENV_KEYCHAIN_IT=1 bun test # macOS: exercise the real login Keychain
SLOPENV_SECRET_STORE_IT=1 bun test # Linux: exercise the session's Secret Service
bunx tsc --noEmit
bun run buildSLOPENV_LOG=1 traces to stderr: which rule won, whether the keychain was hit.
CI runs the suite, a typecheck, and a native binary smoke test on macOS and Linux for every push. Pushing a v* tag builds arm64 and x64 binaries for both operating systems and attaches them to a GitHub release with SHA256SUMS. The workflow refuses to publish if the tag and package.json version disagree, since slopenv --version reads the latter.
The suite covers path matching (nesting, sibling prefixes, symlinks), the diff and restore semantics (enter, leave, re-enter, nested override, pre-existing value), shell quoting of 19 hostile values against real zsh and real bash, the rules-file round trip, lock behaviour, and an end-to-end zsh session that cds around and reads the environment back, including checks on how many times the binary was spawned. Completion is tested by driving an interactive zsh through a pty and pressing TAB, since a completion script that loads is not the same as one that works. update is tested end to end against a fake release served from a local HTTP server, including a corrupted download and a missing checksum file, so the failure paths are exercised without the network.
# Delete every keychain entry and rule slopenv owns.
slopenv list --json | grep -o '"name": "[^"]*"' # review first
slopenv rm NAME DIR # per rule, deletes the keychain entry too
rm -rf ~/.slopenv
rm ~/.local/bin/slopenvThen remove the eval "$(slopenv hook zsh)" line from ~/.zshrc. Any leftover keychain entries live under the service name slopenv and can be found in Keychain Access by searching for it.