I kept rebuilding the same little secret resolver.
The first version was not a package. It was just a helper tucked into whatever script needed it that week. Try 1Password first. If that fails, try an environment variable. Do not print the token. Do not write it to disk. Report enough provenance that I can see whether a command used op or an env var, but never report the value.
This grew out of the same habit I wrote about in 1Password CLI for Developer Secrets: keep the secret in the password manager, and let local tooling hold only the reference path and the runtime value.
That pattern showed up in enough local CLIs that the copies started to drift. Each one had the same basic shape, but slightly different names, error messages, and fallback behavior. Not broken, exactly, but ad hoc in the way local tooling gets when it grows from real use instead of a design doc.
So I pulled it out into a small package: secretpath.
uv add secretpathOr, for the CLI:
uvx secretpath doctorThe core idea is simple:
from secretpath import resolve_named_secret
api_token = resolve_named_secret("service").valueConfig stores lookup metadata, not secrets:
[defaults]
provider = "auto"
[secrets.service]
env_var = "SERVICE_API_KEY"
op_reference = "op://Private/Service API/token"
[secrets.backup]
env_var = "BACKUP_API_KEY"
op_reference = "op://Private/Backup API/token"With that in ~/.config/secretpath/config.toml, local tools can agree on names like service and backup without each project carrying its own 1Password wiring.
The CLI is mostly for checking and setup:
sp check service
sp list
sp doctorsp check verifies that a secret resolves without printing it:
Resolved service from 1password.
If it falls back to an environment variable, the source includes the variable name:
Resolved service from env:SERVICE_API_KEY.
That source label has turned out to be useful. It is safe enough for logs and run records, and it answers the question I actually care about: where did this credential come from?
There is also a small direnv helper, which is a cleaner version of the project-local pattern from direnv for Project-Specific Secrets:
sp direnv init service backupThat writes a managed .envrc block:
# >>> secretpath direnv >>>
# Generated by secretpath. Review before running: direnv allow
watch_file ~/.config/secretpath/config.toml
export SERVICE_API_KEY="$(op read op://Private/Service API/token)"
export BACKUP_API_KEY="$(op read op://Private/Backup API/token)"
# <<< secretpath direnv <<<This is intentionally not sp printing secrets. secretpath reads the config and writes the op read calls. direnv runs those calls when I enter the directory. 1Password remains the thing that actually releases the credential.
That boundary matters.
secretpath is not a vault. It does not try to be an encrypted local cache. It does not store durable secrets. It does not add an audit log. 1Password already handles storage, unlock, rotation, and audit. Environment variables remain the fallback for CI, production, and cases where op is the wrong tool.
The package is deliberately small because the problem is small:
- resolve a named secret
- prefer 1Password
- fall back to env
- cache inside the current Python process
- never print or persist the value
- make local setup less fiddly
The source is on GitHub: https://github.com/olearydj/secretpath
The package is on PyPI: https://pypi.org/project/secretpath/
If nothing else, it is nice to delete three slightly different versions of the same helper.