CLI reference
envless has nine subcommands plus a root version flag. The surface
is deliberately flat — no nested groups, no hidden commands.
envless --version
envless initenvless set KEY [--env=ENV]envless get KEY [--env=ENV] --confirmenvless list [--env=ENV]envless exec [--env=ENV] -- CMD [ARGS...]envless migrate FILE [--env=ENV] [--keep]envless backup [--output PATH] [--include-identity] [--yes]envless mcp # JSON-RPC 2.0 stdio MCP serverenvless daemon [install|uninstall|status|stop] # optional decrypt-cache daemonDefault for --env is always dev.
Subcommands
envless --version
Prints the version baked into the binary at build time
(-ldflags "-X main.version=…"). Exits 0.
$ envless --versionenvless version v0.0.1envless init
Creates the .envless/ directory and a local age identity.
Idempotent — re-running with an existing identity.key is a no-op.
- Creates
.envless/with mode0700. - Runs
age-keygen -o .envless/identity.key. - Chmods the key file to
0600. - Writes the new pubkey as the sole line of
.envless/recipients.
Output:
INIT identity=.envless/identity.key pubkey=age1...Takes no positional args. Fails if age-keygen is not on PATH.
envless set KEY
Reads the value from stdin (the trailing \n is stripped) and
writes it to the encrypted store for --env=ENV.
| Flag | Default | Description |
|---|---|---|
--env | dev | Target environment name. Becomes the prefix of secrets/<env>.env.enc. |
Output:
SET env=dev key=OPENAI_API_KEYThe value never appears on stdout or stderr. Read-modify-write of the existing env file under the hood.
envless get KEY
Prints the plaintext value. Requires --confirm — printing a secret
must be intentional.
| Flag | Default | Description |
|---|---|---|
--env | dev | Target environment. |
--confirm | false | Required. Without it, the command errors out with printing a secret requires --confirm (exit 1). |
Output (with --confirm):
sk-test-xyzIf the key is missing for the given env, exit 1 with:
key "OPENAI_API_KEY" not found in env "dev".
For programmatic reads, prefer envless exec so the plaintext does
not hit a shell.
envless list
Prints all keys for the env, one per line, sorted alphabetically. Values are never printed.
| Flag | Default | Description |
|---|---|---|
--env | dev | Target environment. |
Output:
ABOPENAI_API_KEYURLEmpty output (exit 0) means no env file or no keys yet.
envless exec [--env=ENV] -- CMD [ARGS...]
The star command. Decrypts the env file, merges its KV map into
os.Environ() (env-file keys override the parent), sorts the merged
list, spawns CMD with that environment, and proxies stdin/stdout/
stderr.
| Flag | Default | Description |
|---|---|---|
--env | dev | Target environment. |
The -- separator is conventional but not strictly required by the
envless dispatcher; include it to avoid ambiguity with envless’s own
flags.
Exit code: child’s exit code on a clean run. See Exit codes below for failure modes.
Example:
envless exec -- node server.jsenvless exec --env=prod -- npm run deployenvless exec -- /bin/sh -c 'echo $OPENAI_API_KEY'envless migrate FILE
One-shot migration from a plaintext .env-style file to an encrypted
env. Idempotently amends .gitignore.
| Flag | Default | Description |
|---|---|---|
--env | dev | Target environment. |
--keep | false | If set, the plaintext source file is not removed after migration. |
Behaviour:
- Reads
FILE, parses viapkg/envparse(handlesKEY=VALUE, quoted values, trailing# comments). - Merges into the existing env file (last-write-wins on duplicate keys; migration overrides).
- Encrypts via
sops. - Appends
basename(FILE)to.gitignore(idempotent; skipped if the pattern is already present). - Removes
FILEunless--keep.
Output (without --keep):
MIGRATE src=.env env=dev keys=3REMOVE .envenvless backup
Bundles envless state — .envless/recipients, every
secrets/<env>.env.enc, and a MANIFEST.json — into a single
tar.gz. The age secret key (.envless/identity.key) is excluded
by default so the resulting tarball is safe to upload to any
storage destination.
| Flag | Default | Description |
|---|---|---|
--output | (stdout) | Path to write the tarball. - or omitted = stream to stdout (composes with rclone rcat, gpg --symmetric, etc.). |
--include-identity | false | Also include .envless/identity.key. Requires interactive confirmation, or --yes in non-interactive contexts. |
--yes | false | Bypass the interactive prompt when --include-identity is set. Required for scripts and cron. |
Resolution: envless backup walks up from the current directory
looking for .envless/identity.key and treats the dir containing it
as the repo root. So you can run envless backup from any
subdirectory of the project.
Tarball layout:
MANIFEST.json.envless/recipients.envless/identity.key # only when --include-identitysecrets/dev.env.encsecrets/prod.env.enc…MANIFEST.json schema (version 1):
{ "schema_version": 1, "envless_version": "v0.1.0", "created_at": "2026-06-04T12:34:56Z", "repo_root": "/Users/alice/my-app", "pubkey": "age1...", "includes_identity": false, "envs": ["dev", "prod"], "file_count": 4}Output (file mode):
BACKUP out=backup.tar.gz identity=excludedOutput (stdout mode): the raw tarball bytes go to stdout, nothing else; status messages all go to stderr so the pipe stays clean.
Examples:
# Safe default — recipients + encrypted envs + manifest, identity excluded.envless backup --output backup-$(date -u +%Y%m%d).tar.gz
# Stream to a cloud sync without a local tempfile.envless backup | rclone rcat gdrive:envless-backups/$(date -u +%Y%m%d).tar.gz
# Identity-included backup, GPG-encrypted before any storage.envless backup --include-identity --yes --output - \ | gpg --symmetric --cipher-algo AES256 \ --output backup.tar.gz.gpgExit codes specific to backup:
0— success.1— user cancelled the--include-identityprompt.2— usage error (e.g.--include-identitywithout--yesin a non-TTY context).64— configuration error: no.envless/identity.keyfound in the current directory or any parent.74— IO error (stage, tar invocation, file copy).
See Operations → Backup & restore for upload-destination guidance and scheduled-backup recipes.
envless mcp
Run the Model Context Protocol server
on stdio. Reads JSON-RPC 2.0 lines from stdin (newline-delimited, no LSP
Content-Length framing — that’s the MCP stdio convention) and writes
responses to stdout. No flags in v1.
Protocol: MCP 2024-11-05, tools-only. Eight tools, all using
draft-7 JSON Schema:
| Tool | Input | Output | Notes |
|---|---|---|---|
envs | {} | {envs: ["dev", "prod"]} | Scans secrets/*.env.enc. |
list | {env} | {keys: ["FOO", "BAR"]} | Sorted, no values. |
get | {env, key, confirm:true} | {value: "..."} | confirm must be exactly true (bool) or "true" (string). Anything else is rejected. |
set | {env, key, value} | {ok: true} | Encrypt-set one key. |
exec | {env, argv, stdin?, cwd?} | {exit_code, stdout, stderr} | Hard 300s timeout. Captures stdout+stderr in memory; cap 16 MiB each. |
init | {path?} | {pubkey: "age1..."} | Defaults path to MCP server cwd. |
migrate | {file, env, keep?} | {count: N} | Same semantics as the migrate subcommand. |
whoami | {} | {pubkey, recipients: N} | Reads .envless/identity.key and .envless/recipients. |
Tool-level errors come back as {content:[{type:"text",text:"..."}], isError:true} per the MCP convention — not as JSON-RPC errors. The
JSON-RPC error codes (-32700 parse, -32600 invalid request,
-32601 method not found, -32602 invalid params, -32603 internal)
are reserved for protocol-level issues.
Stateless model: every tools/call is independent — no session state,
no in-memory cache between calls. When the daemon
socket exists at $XDG_RUNTIME_DIR/envless/sock (or
$HOME/.cache/envless/sock) and answers PING within ~100ms, calls
route through it for low-latency repeated reads. Otherwise the MCP
process spawns sops fresh on each call, identical to the CLI path.
Example session (one request per line, one response per line):
$ envless mcp{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude","version":"1"}}}{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"envless","version":"v0.1.0"}}}{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}{"jsonrpc":"2.0","id":2,"method":"tools/list"}{"jsonrpc":"2.0","id":2,"result":{"tools":[...8 entries...]}}{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"envs","arguments":{}}}{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"envs\":[\"dev\",\"prod\"]}"}],"isError":false}}Wiring envless MCP into Claude Code / Codex / Cursor / Cline is covered on the Agents (MCP) page.
envless daemon
Run the optional decrypt-cache daemon. Not auto-started: you opt in
by running envless daemon install. This is intentional — the daemon
holds decrypted env in memory for up to 60s and is documented under the
ptrace tier in Security.
envless daemon # foreground (for launchd/systemd to manage)envless daemon install # install LaunchAgent (macOS) or user unit (Linux)envless daemon uninstall # remove + bootout/disableenvless daemon status # supervisor + socket probeenvless daemon stop # send SIGTERM via launchctl/systemctlWire protocol: TAB-separated lines over a UNIX stream socket at
$XDG_RUNTIME_DIR/envless/sock (preferred) or $HOME/.cache/envless/sock.
PING\n → OK\t{}\nWHOAMI\n → OK\t{"pubkey":"age1...","recipients":3}\nLIST\t<env>\n → OK\t{"keys":[...]}\nGET\t<env>\t<key>\n → OK\t{"value":"..."}\nSET\t<env>\t<key>\t<value>\n → OK\t{"ok":true}\nEXEC\t<env>\t<cwd>\t<argv-b64>\t<stdin-b64>\t<timeout-ms>\n → OK\t{"exit_code":N,...}\nError responses use ERR\t{"code":"...","message":"..."}\n. argv-b64
is base64(JSON-array-of-strings) so embedded TABs/newlines round-trip.
Cache:
- Keyed by
(repo_root, env). - Invalidated on file mtime change (so an out-of-band
envless setis observed cleanly). - TTL 60s.
- LRU bounded to 32 entries.
- Best-effort
secureZeroof value bytes before free on shutdown.
Supervisor integration:
- macOS — writes
~/Library/LaunchAgents/io.github.biliboss.envless.plistand runslaunchctl bootstrap gui/$UID …. KeepAlive usesSuccessfulExit=falseso a clean exit stays down. - Linux — writes
~/.config/systemd/user/envless.service(or$XDG_CONFIG_HOME/systemd/user/), runssystemctl --user daemon-reload && systemctl --user enable --now envless.
Override the label/unit name via ENVLESS_LAUNCHD_LABEL /
ENVLESS_SYSTEMD_UNIT (used by tests so a CI bootstrap doesn’t fight a
developer’s real install).
Exit codes
envless follows Unix conventions: 0 on success, non-zero on
failure. The two interesting nuances are exec, which propagates the
child’s exit code verbatim, and get, which fails fast without
--confirm.
| Code | Where it comes from | Meaning |
|---|---|---|
0 | every subcommand | success |
1 | envless dispatcher | unhandled error (missing arg, parse failure, file not found, sops error, get without --confirm, etc.) |
| N | envless exec | child process exited with code N (propagated via execenv.RunResult.non_zero) |
127 | shell or OS | binary not found on PATH — comes from the shell, not from envless |
Behaviour by subcommand
envless --version—0always (assuming the binary launched at all).envless init—0on success or no-op (identity already exists).1ifage-keygenis missing or fails. Stderr includesstore: age-keygen: ...and the capturedage-keygenstderr.envless set—0on success.1if stdin read fails, if recipients file is empty, or ifsops encrypterrors.envless get—0and prints the value on success.1if--confirmis absent (stderr:printing a secret requires --confirm).1if the key is not in the env (stderr:key "X" not found in env "Y").1ifsops decrypterrors.envless list—0on success, including the empty-set case.1on decrypt or store errors.envless exec— child exit code, verbatim, when the child runs and exits normally or with an error code.envlessitself does not interpret the code.1if--is followed by no command (exec: missing command).1if decryption fails before the child starts. Shell-level127if the command binary is not found — that is the OS exec failure, not anenvlessexit. Zig’sstd.process.Childsurfaces spawn failures as a build-side error, which envless wraps and exits with1.envless migrate—0on success (and on--keep).1if the source file is missing, unreadable, fails to parse, or the encrypt step fails.envless backup—0on success.1if the user cancels the--include-identityprompt.2for usage errors, including--include-identityin a non-TTY context without--yes.64if no.envless/identity.keyis found by walking up from cwd.74for IO failures during staging or tar invocation.
Implementation reference
The exec propagation is the only non-trivial case. From
zig/src/cli/exec.zig:
const res = execenv.run(allocator, child_argv, child_env, ctx.stdin, ctx.stdout, ctx.stderr) catch |err| { try ctx.stderr.writer().print("envless: exec: {s}\n", .{@errorName(err)}); return 1;};return switch (res) { .success => 0, .non_zero => |code| code,};And the RunResult definition from
zig/src/execenv.zig:
pub const RunResult = union(enum) { success, non_zero: u8,};Any error returned from the run path is printed to stderr and the
dispatcher exits with status 1.
Scripting against envless exec
Because exec propagates exactly, the common shell idiom works:
envless exec -- ./run-tests.shecho "tests exited $?"Or, for fail-fast pipelines:
set -euo pipefailenvless exec -- ./run-tests.shenvless exec -- ./deploy.shEnvironment variables
envless is configured almost entirely by files and flags. It reads
exactly one external env var, sets one for child processes (sops
decryption), and forwards every variable in the parent’s environment
into exec children.
Read by envless
| Variable | Read by | Purpose |
|---|---|---|
PATH | OS / std.process.Child | Locating age-keygen and sops. Standard PATH-search semantics. |
That is the entire intentional read set in v0.0.1. Anything else the
Zig stdlib touches (HOME, TMPDIR) is incidental and not part of
the contract.
Set by envless (for child processes)
| Variable | Set by | Purpose |
|---|---|---|
SOPS_AGE_KEY_FILE | internal/sopswrap.Decrypt | Points sops at .envless/identity.key. Set only on the sops decrypt child process — not exported to your shell. |
The sops call (sops.zig) builds its env by writing
into a fresh std.process.EnvMap populated from os.Environ() plus
the SOPS_AGE_KEY_FILE entry, then handing that map to
std.process.Child.env_map. The override only applies for the duration
of the decrypt child.
Forwarded into envless exec children
envless exec constructs the child env by merging:
- Every
KEY=VALUEfromos.Environ()(the envless process’s parent env). - Every entry from the decrypted secrets map. Secrets override matching parent keys.
The merged set is sorted lexicographically and passed as the child’s
env_map. The merge logic is in execenv.buildEnv:
for (parent) |e| { const eq = std.mem.indexOfScalar(u8, e, '=') orelse continue; try merged.put(e[0..eq], e[eq + 1 ..]);}var it = kv.iterator();while (it.next()) |entry| { try merged.put(entry.key_ptr.*, entry.value_ptr.*);}Note: secrets win. If your shell exports OPENAI_API_KEY=local and
the env file contains OPENAI_API_KEY=sops, the child sees sops.
Variables NOT defined by envless
For clarity, envless does not read or honour any of the
following — listed because users sometimes expect them by analogy
with sops, dotenv-vault, or direnv:
ENVLESS_HOME,ENVLESS_CONFIG,ENVLESS_NO_COLOR, etc. — not implemented.SOPS_AGE_RECIPIENTS— sops respects this for encryption, butenvlessalways reads recipients from.envless/recipients, so settingSOPS_AGE_RECIPIENTShas no effect onenvless-mediated calls.AGE_IDENTITY— convention for storing the secret key in CI, butenvlessdoes not read it directly. Materialise the file (see Operations → CI/CD).
File formats
envless operates on five files. Knowing their format makes manual
debugging and recovery trivial.
.envless/identity.key
The age secret key. Generated by age-keygen on envless init.
- Format: age private-key file, three lines:
# created: 2026-01-01T00:00:00Z# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pAGE-SECRET-KEY-1XQ...
- Mode:
0600(enforced byenvless). - Git:
.gitignoreincludes.envless/identity.keyby default. - Read by:
envless(store.PubKey()),sops(viaSOPS_AGE_KEY_FILE).
The pubkey is extracted by scanning for the # public key: marker
— this is part of the age file format and stable across versions.
.envless/recipients
The access-control list. One age public key per line.
- Format: UTF-8 text. Blank lines and
# commentsignored. - Mode:
0644(committed). - Git: committed.
- Read by:
envless(store.Recipients()), passed tosops encrypt --age <comma-separated>.
Example:
# Humansage1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p # aliceage1z6f0ldygfp22k0w50zss5pl4kxvkzlulhpqcmm8m65rt6q08vrnsq8ec6t # bob# CIage1mh6gh39mhqaeu2cysr0gwz2ts83krppyzj8nrz86g2yax7gx7scqgca2x9A pubkey with a trailing inline comment on the same line is treated
as a single token by store.Recipients() — put comments
on their own line. Empty file = the next encrypt operation fails with
store: no recipients.
secrets/<env>.env.enc
The encrypted secret store. Output of sops encrypt --input-type dotenv --output-type dotenv.
- Format: sops dotenv envelope. Plain key names + AES-GCM
encrypted values + sops metadata block at the end:
OPENAI_API_KEY=ENC[AES256_GCM,data:...]STRIPE_KEY=ENC[AES256_GCM,data:...]sops_age__list_0__map_recipient=age1...sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----...sops_lastmodified=2026-01-01T00:00:00Zsops_mac=ENC[AES256_GCM,data:...]sops_unencrypted_suffix=_unencryptedsops_version=3.9.4
- Mode:
0644. - Git: committed.
- Read by:
sops decrypt(withSOPS_AGE_KEY_FILE).
Plaintext form before encryption (rendered by
sops.renderDotenv):
- Sorted keys (lexicographic, byte-wise).
KEY=VALUE\nper line.- No quoting. Values containing
\nwill break the dotenv format — treat them as out of scope for v0.0.1.
.gitignore
envless migrate appends the source filename, idempotently.
- Format: standard gitignore.
- Behaviour: the migrate command reads
.gitignore, splits on\n, trims whitespace, and skips if a line equalsbasename(src). Else appends<basename>\n(with a leading newline if the file did not end with one).
The repo ships with the following lines pre-set:
.envless/identity.key.envless/inbox/.astro/dist/node_modules/.env (input only)
The plaintext source for envless migrate. Parsed by
zig/src/envparse.zig:
- One assignment per line:
KEY=VALUE. #-prefixed lines and blank lines: ignored.- Quoted values (
"..."or'...'): unquoted, no escape sequences interpreted. Quotes must close on the same line. - Unquoted values: trailing
# comment(space +#) is stripped. - No
$VARinterpolation. No multi-line values.
If a value contains characters that round-trip through dotenv badly (newlines, NUL bytes), behaviour is undefined. v0.0.1 keeps the parser deliberately small; richer parsing lands in v0.1.
Implementation pointers
| Subcommand | Source |
|---|---|
init | zig/src/cli/init.zig |
set | zig/src/cli/set.zig |
get | zig/src/cli/get.zig |
list | zig/src/cli/list.zig |
exec | zig/src/cli/exec.zig |
migrate | zig/src/cli/migrate.zig |
backup | zig/src/cli/backup.zig + zig/src/backup.zig |
mcp | zig/src/cli/mcp.zig, zig/src/mcp.zig |
daemon | zig/src/cli/daemon.zig, zig/src/daemon.zig, zig/src/ipc.zig, zig/src/launchd.zig, zig/src/systemd.zig |
| root wiring | zig/src/cli/root.zig |