Skip to content

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 init
envless set KEY [--env=ENV]
envless get KEY [--env=ENV] --confirm
envless 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 server
envless daemon [install|uninstall|status|stop] # optional decrypt-cache daemon

Default 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 --version
envless version v0.0.1

envless 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 mode 0700.
  • 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.

FlagDefaultDescription
--envdevTarget environment name. Becomes the prefix of secrets/<env>.env.enc.

Output:

SET env=dev key=OPENAI_API_KEY

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

FlagDefaultDescription
--envdevTarget environment.
--confirmfalseRequired. Without it, the command errors out with printing a secret requires --confirm (exit 1).

Output (with --confirm):

sk-test-xyz

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

FlagDefaultDescription
--envdevTarget environment.

Output:

A
B
OPENAI_API_KEY
URL

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

FlagDefaultDescription
--envdevTarget 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:

Terminal window
envless exec -- node server.js
envless exec --env=prod -- npm run deploy
envless 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.

FlagDefaultDescription
--envdevTarget environment.
--keepfalseIf set, the plaintext source file is not removed after migration.

Behaviour:

  1. Reads FILE, parses via pkg/envparse (handles KEY=VALUE, quoted values, trailing # comments).
  2. Merges into the existing env file (last-write-wins on duplicate keys; migration overrides).
  3. Encrypts via sops.
  4. Appends basename(FILE) to .gitignore (idempotent; skipped if the pattern is already present).
  5. Removes FILE unless --keep.

Output (without --keep):

MIGRATE src=.env env=dev keys=3
REMOVE .env

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

FlagDefaultDescription
--output(stdout)Path to write the tarball. - or omitted = stream to stdout (composes with rclone rcat, gpg --symmetric, etc.).
--include-identityfalseAlso include .envless/identity.key. Requires interactive confirmation, or --yes in non-interactive contexts.
--yesfalseBypass 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-identity
secrets/dev.env.enc
secrets/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=excluded

Output (stdout mode): the raw tarball bytes go to stdout, nothing else; status messages all go to stderr so the pipe stays clean.

Examples:

Terminal window
# 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.gpg

Exit codes specific to backup:

  • 0 — success.
  • 1 — user cancelled the --include-identity prompt.
  • 2 — usage error (e.g. --include-identity without --yes in a non-TTY context).
  • 64 — configuration error: no .envless/identity.key found 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:

ToolInputOutputNotes
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/disable
envless daemon status # supervisor + socket probe
envless daemon stop # send SIGTERM via launchctl/systemctl

Wire 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{}\n
WHOAMI\n → OK\t{"pubkey":"age1...","recipients":3}\n
LIST\t<env>\n → OK\t{"keys":[...]}\n
GET\t<env>\t<key>\n → OK\t{"value":"..."}\n
SET\t<env>\t<key>\t<value>\n → OK\t{"ok":true}\n
EXEC\t<env>\t<cwd>\t<argv-b64>\t<stdin-b64>\t<timeout-ms>\n → OK\t{"exit_code":N,...}\n

Error 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 set is observed cleanly).
  • TTL 60s.
  • LRU bounded to 32 entries.
  • Best-effort secureZero of value bytes before free on shutdown.

Supervisor integration:

  • macOS — writes ~/Library/LaunchAgents/io.github.biliboss.envless.plist and runs launchctl bootstrap gui/$UID …. KeepAlive uses SuccessfulExit=false so a clean exit stays down.
  • Linux — writes ~/.config/systemd/user/envless.service (or $XDG_CONFIG_HOME/systemd/user/), runs systemctl --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.

CodeWhere it comes fromMeaning
0every subcommandsuccess
1envless dispatcherunhandled error (missing arg, parse failure, file not found, sops error, get without --confirm, etc.)
Nenvless execchild process exited with code N (propagated via execenv.RunResult.non_zero)
127shell or OSbinary not found on PATH — comes from the shell, not from envless

Behaviour by subcommand

  • envless --version0 always (assuming the binary launched at all).
  • envless init0 on success or no-op (identity already exists). 1 if age-keygen is missing or fails. Stderr includes store: age-keygen: ... and the captured age-keygen stderr.
  • envless set0 on success. 1 if stdin read fails, if recipients file is empty, or if sops encrypt errors.
  • envless get0 and prints the value on success. 1 if --confirm is absent (stderr: printing a secret requires --confirm). 1 if the key is not in the env (stderr: key "X" not found in env "Y"). 1 if sops decrypt errors.
  • envless list0 on success, including the empty-set case. 1 on decrypt or store errors.
  • envless execchild exit code, verbatim, when the child runs and exits normally or with an error code. envless itself does not interpret the code. 1 if -- is followed by no command (exec: missing command). 1 if decryption fails before the child starts. Shell-level 127 if the command binary is not found — that is the OS exec failure, not an envless exit. Zig’s std.process.Child surfaces spawn failures as a build-side error, which envless wraps and exits with 1.
  • envless migrate0 on success (and on --keep). 1 if the source file is missing, unreadable, fails to parse, or the encrypt step fails.
  • envless backup0 on success. 1 if the user cancels the --include-identity prompt. 2 for usage errors, including --include-identity in a non-TTY context without --yes. 64 if no .envless/identity.key is found by walking up from cwd. 74 for 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:

Terminal window
envless exec -- ./run-tests.sh
echo "tests exited $?"

Or, for fail-fast pipelines:

Terminal window
set -euo pipefail
envless exec -- ./run-tests.sh
envless exec -- ./deploy.sh

Environment 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

VariableRead byPurpose
PATHOS / std.process.ChildLocating 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)

VariableSet byPurpose
SOPS_AGE_KEY_FILEinternal/sopswrap.DecryptPoints 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=VALUE from os.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, but envless always reads recipients from .envless/recipients, so setting SOPS_AGE_RECIPIENTS has no effect on envless-mediated calls.
  • AGE_IDENTITY — convention for storing the secret key in CI, but envless does 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: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
    AGE-SECRET-KEY-1XQ...
  • Mode: 0600 (enforced by envless).
  • Git: .gitignore includes .envless/identity.key by default.
  • Read by: envless (store.PubKey()), sops (via SOPS_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 # comments ignored.
  • Mode: 0644 (committed).
  • Git: committed.
  • Read by: envless (store.Recipients()), passed to sops encrypt --age <comma-separated>.

Example:

# Humans
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p # alice
age1z6f0ldygfp22k0w50zss5pl4kxvkzlulhpqcmm8m65rt6q08vrnsq8ec6t # bob
# CI
age1mh6gh39mhqaeu2cysr0gwz2ts83krppyzj8nrz86g2yax7gx7scqgca2x9

A 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:00Z
    sops_mac=ENC[AES256_GCM,data:...]
    sops_unencrypted_suffix=_unencrypted
    sops_version=3.9.4
  • Mode: 0644.
  • Git: committed.
  • Read by: sops decrypt (with SOPS_AGE_KEY_FILE).

Plaintext form before encryption (rendered by sops.renderDotenv):

  • Sorted keys (lexicographic, byte-wise).
  • KEY=VALUE\n per line.
  • No quoting. Values containing \n will 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 equals basename(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 $VAR interpolation. 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

SubcommandSource
initzig/src/cli/init.zig
setzig/src/cli/set.zig
getzig/src/cli/get.zig
listzig/src/cli/list.zig
execzig/src/cli/exec.zig
migratezig/src/cli/migrate.zig
backupzig/src/cli/backup.zig + zig/src/backup.zig
mcpzig/src/cli/mcp.zig, zig/src/mcp.zig
daemonzig/src/cli/daemon.zig, zig/src/daemon.zig, zig/src/ipc.zig, zig/src/launchd.zig, zig/src/systemd.zig
root wiringzig/src/cli/root.zig