@phnx-labs/secrets-cli
v0.3.3
Published
Portable secret bundles, native stores and provider-backed injection
Downloads
2,686
Readme
secrets
Named bundles of environment variables, stored in the OS keychain, an encrypted file, or an age-encrypted vault — and injected into a child process without ever printing them.
npm i -g @phnx-labs/secrets-cli
secrets create prod --description "Production keys for the api stack"
secrets add prod STRIPE_API_KEY # prompts; value never echoes
secrets add prod LOG_LEVEL --value info # a non-sensitive literal
secrets exec prod -- ./deploy.sh # values ride the child env onlysecrets runs on its own. It has no fleet registry and no dependency on
agents-cli. Other programs drive it through a bounded private pipe
(secrets __serve) or the SDK (@phnx-labs/secrets-cli).
Reading a named bundle from another program
@phnx-labs/secrets-cli/client is the process client for that pipe. Each call
runs the installed secrets __serve ($SECRETS_BIN, else secrets on PATH)
and exchanges one request over private fd 3 / fd 4 pipes, so the engine's
access policy, grants and expiry checks apply and values never touch argv or
stdout. It imports no provider SDKs.
import { readAndResolveBundleEnvSync, isSecretsClientError } from '@phnx-labs/secrets-cli/client';
const { env } = readAndResolveBundleEnvSync(
'r2.backups',
{ caller: 'session-transport', agentOnly: true },
{ allowedBundles: ['r2.backups'] },
);readAndResolveBundleEnv is the async form, and secretsRequest /
secretsRequestSync send any other protocol operation. Failures are
SecretsClientError with a code: policy and data codes such as
ACCESS_DENIED, NOT_FOUND and EXPIRED are authoritative, and only the codes
isSecretsTransportError accepts (missing binary, timeout, bad response) mean
the engine could not be reached. An explicit SECRETS_HOME is passed through
and an unset one stays unset. AGENTS_SECRETS_PASSPHRASE is accepted as an
alias for SECRETS_PASSPHRASE. The client runs a JavaScript engine under
node, also from a Bun caller, so Node must be on PATH there. The synchronous
form runs the engine in its own process group and kills that group when the
call ends, including on TIMEOUT. It is POSIX only and fails with
SYNC_UNSUPPORTED on Windows.
Before its first data request the client sends the engine a handshake, once
per process. An engine that reports a lower engine contract than the client's
ENGINE_CONTRACT (every release up to 0.3.0) is refused with
ENGINE_INCOMPATIBLE before any data operation reaches it, because it may read
a different state root. This code is authoritative, not a transport error.
checkSecretsEngine / checkSecretsEngineSync run the same check and return the
handshake (protocol, contract, version). A push (pushBundleToHost and its
siblings) checks its receiver the same way, through secrets __handshake over
SSH under the same state root. A lower contract there returns
{ ok: false, code: 'ENGINE_INCOMPATIBLE' } and nothing is sent.
On macOS, secrets start (or the first unlock) spawns a user-local broker:
secrets _agent-run. That is not the agents daemon. Socket and pid live at
$SECRETS_HOME/.cache/helpers/secrets-agent/ (default ~/.agents/.secrets). Linux
has no broker.
With agents-cli
agents setup secrets # install if missing, then secrets migrate
agents secrets list # passthrough to this binary; SECRETS_HOME=~/.agentsWhere things live
Everything hangs off SECRETS_HOME (default ~/.agents/.secrets). When a
harness swaps HOME for its own configuration and keeps the user's home in
AGENTS_REAL_HOME (Cursor under agents), the default root and the legacy roots
it adopts from resolve under AGENTS_REAL_HOME. HOME is not changed, and an
explicit SECRETS_HOME still wins.
| Path | What |
|---|---|
| secrets.yaml | defaults (backend, policy, agent.*) and named provider connections |
| .cache/secrets/ | the AES-256-GCM encrypted file store |
| .secrets-key/ | the 0600 machine-local key for that store |
| vault.age | the age-encrypted synced vault |
| .cache/helpers/secrets-agent/ | the macOS broker's socket, pid and token |
| .cache/events.jsonl | the value-free event log (secrets activity) |
The first time a command finds no store there, it adopts the stores of the
earlier roots ~/.agents and ~/.secrets, re-encrypting each under the new
root's key and leaving the old directories in place. A bundle that differs
between them stops every command until secrets migrate --prefer <root> picks
a copy; secrets migrate --dry-run shows the plan. macOS Keychain items are
keyed by service name, not by path, so they are unaffected.
Backends
- keychain (macOS) — items live in the login keychain behind Touch ID. The
broker (
secrets unlock,secrets status) holds an unlocked bundle in memory for the hold window (7 days;secrets policy --hold 8hchanges it) so concurrent readers do not each prompt. One unlock, of one bundle or several, asks for Touch ID once. A locked bundle read from your GUI session — by you or by an agent you are driving — raises one Touch ID sheet and is then held; only an unattended run (AGENTS_RUNTIME=headless) or a process outside the GUI session (SSH, launchd, CI) refuses and asks forsecrets unlock. Holds survive a broker restart or upgrade;secrets unlock --durable(orsecrets.agent.durable: true) makes them survive sleep too.secrets unlock <bundle> --scope <scope>narrows that grant to one opaque scope (a harness name, a job id); a reader passes the same scope (SECRETS_SCOPE) to see it. Without--scopethe grant is global. - file — AES-256-GCM under a machine-local key; readable headlessly.
- vault — age-encrypted file you sync yourself (
secrets vault unlock).
Commands
secrets list [query] secrets view <name> secrets activity [name]
secrets create [name] secrets add <bundle> <key> secrets rotate <bundle> <key>
secrets remove <bundle> <key> secrets rename <old> <new> secrets delete <name>
secrets describe <name> [text] secrets policy [bundle] [p] secrets generate [length]
secrets exec <bundle> -- <cmd> secrets get <item> [key] secrets set <item>
secrets import [bundle] … secrets export [bundle] … secrets migrate
secrets unlock [names…] secrets lock [names…] secrets status
secrets start | stop secrets vault unlock | lock secrets push | pull | remote-list
secrets import-keyring secrets migrate-acl secrets rekey | rotate-passphrase
secrets hosts pin | list secrets mcpsecrets <command> --help prints an ordered example sequence before the flags.
secrets status --json reports the broker and held bundles without values:
{"broker":{"running":true},"holdMs":604800000,"held":[{"name","keys","expiresAt","durable","survives"}]}.
See docs/secrets.md for the fields.
scripts/generate-cli-docs.sh # refresh docs/
scripts/generate-cli-docs.sh --check # fail on stale docs
scripts/generate-cli-docs.sh --out-dir .agents/scratch/cli-docs # previewRun after bun install --frozen-lockfile. The script works from any directory;
--out-dir is relative to your current directory. Open command-reference.html
from the output folder to browse the tree and search commands.
CI uploads a secrets-cli-docs artifact on pull requests and pushes to main
or release/**. It contains the HTML reference plus Markdown and JSON indexes.
Tests and releases check the committed files; commit regenerated docs/ with
command changes. Release checks also run with --skip-tests / --skip-build.
Every command's arguments, options, examples and notes:
docs/command-reference.html (searchable, with
the command tree in a sidebar) and docs/command-index.md.
Both are generated from the registered command tree by scripts/generate-cli-docs.sh;
scripts/test.sh fails when they are stale.
Remote hosts
--host <target> on exec, import, export, list, view and unlock
takes the address grammar shared by every Phoenix CLI (ssh://[user@]host,
or a bare OpenSSH alias / user@host, exactly as ssh would) — secrets
speaks the ssh scheme only, so vnc://, cdp:// and any other scheme fail
loud, and so does a port on the address (put it in ~/.ssh/config instead).
exec --host resolves the bundle on that host and runs the child locally;
to run the child remotely, put secrets exec on the other side of ssh.
Credential transfer refuses a host whose key is not pinned (secrets hosts
pin <target>).
list and view also take --hosts a,b. With one host, --json prints that
host's own JSON unchanged. With several, it prints one document,
{"hosts":[{"host":"a","ok":true,"result":…},{"host":"b","ok":false,"error":"…"}]},
where result is what --host a --json would print and error carries only the
ssh or exit reason and the remote's stderr. Any failed host makes the command exit 1.
With --reveal the session runs on a PTY, which merges the remote's stderr into
stdout, so a failed host's error is only the exit reason.
Development
bun install
bash scripts/test.sh # vitest, real stores and processes, no mocks
scripts/generate-cli-docs.sh # regenerate docs/command-* after a help change
bash scripts/build.sh # tsc -> dist/
bash scripts/install.sh # side-by-side dev install as `secrets-dev`
bash scripts/release.sh <ver> # dry run; --apply publishes and smoke-installsRelease
Push a branch named release/x.y.z or release/x.y.z-pre.n (pre-tag optional).
GitHub Actions tests, packs a tarball that must not contain src/ or .ts
sources, publishes @phnx-labs/secrets-cli to npm (latest, or next for
-pre.n), and tags v<version>.
The workflow publishes through npm trusted publishing: no npm token is stored in
the repository. The package owner sets it up once, with a 2FA login:
npm trust github @phnx-labs/secrets-cli --repo phnx-labs/secrets-cli --file release.yml
(or npmjs.com → the package → Settings → Trusted publisher). See
docs/RELEASE.md.
The npm package ships compiled dist/**/*.js and dist/**/*.d.ts — no
TypeScript sources, no source maps, no src/. Before publishing,
scripts/verify-client-package.sh installs the tarball into a clean prefix and
reads a synthetic bundle through the public client and that install's real
secrets.
See AGENTS.md for the contract this repository holds itself to.
