- Go 96.4%
- Just 3.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| cmd/secret | ||
| internal | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| .golangci.yml | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| README.md | ||
secrets
secrets is a small Go CLI for managing credentials in the macOS
Keychain. The compiled binary is called secret (singular) — one
credential per invocation.
It is the successor of a set of shell helpers (secret-set,
secret-get, secret-list, secret-delete) built on top of the
security(1) tool that ships with macOS.
Credential names are opaque strings you choose. The tool imposes no naming scheme; pick whatever fits your workflow.
Platform: macOS only. Other platforms have no Keychain, so the tool has nothing meaningful to do there.
Status
This repository is being built up in small, reviewable steps.
- Step #1: basic CLI scaffolding — commands, help, unit tests, build tooling. Done.
- Step #2 (current): macOS Keychain backend wired to every
subcommand; interactive masked prompt for
set; distinct exit codes; drop-in compatible with the original shell helpers. Done. - Step #3 (next): richer
listoutput (JSON, grouping by authority), shell completions, release automation.
Installation
From source (recommended for now)
git clone https://gitlab.com/doertydoerk/secrets.git
cd secrets
just install # installs `secret` into $GOBIN (or $GOPATH/bin)
Or, once the module is publicly available:
go install gitlab.com/doertydoerk/secrets/cmd/secret@latest
Local build
just build # produces ./secret in the repo root
./secret --version
Usage
secret set <name> create or update a credential (interactive prompt)
secret get <name> print a credential value to stdout
secret list list every managed credential
secret list -p <prefix> list only names starting with <prefix>
secret delete <name> delete a credential (alias: rm)
secret help [command] general help or help for a specific command
secret --version print version, commit and build date
Names are opaque strings — the tool never rewrites them. secret set foo stores the credential exactly as foo; secret get foo reads it
back.
Entering the value for set
The credential value is never taken from the command line, so it
cannot leak into ps, shell history, or scrollback.
-
Interactive (stdin is a terminal): you are prompted for the value; each character is echoed as
*.$ secret set github_com_personal Value: **************** $ -
Piped (stdin is not a terminal): the value is read from stdin. Exactly one trailing
\n(or\r\n) is stripped — everything else is preserved byte-for-byte.printf 'sk-xxxxxxxxxxxxxxxx' | secret set openai_chatbot_backend op read op://vault/item/password | secret set aws_prod_terraform
Examples
# Create / update
secret set github_com_personal # prompts for value
printf 'sk-xxx' | secret set openai_personal
# Retrieve (pipe-friendly; ends with a newline)
export GITHUB_TOKEN="$(secret get github_com_personal)"
# List everything managed by this tool
secret list
# List only entries whose name starts with a given prefix
secret list -p github_
# Delete
secret delete github_com_personal
secret rm openai_chatbot_local
Exit codes
| Code | Meaning |
|---|---|
0 |
success |
1 |
generic error (validation, unexpected I/O) |
2 |
credential not found (get / delete) |
How it maps to the Keychain
Under the hood every credential is a generic password item with:
- account (
-a) =secret(fixed — required bysecurity(1); used consistently onset/get/deletefor drop-in compatibility with the original shell helpers). - service (
-s) = the credential name, verbatim as you provided it (e.g.github_com_personal). - password (
-w) = the secret value.
secret list is different: it reads every service name in the
login keychain (the Go equivalent of
security dump-keychain … | awk … | sort -u), regardless of account,
so entries created by other tools also appear. Use -p <prefix> to
narrow the output.
That is the same schema used by the original shell helpers, which means the Go tool is drop-in compatible with credentials created by the shell version and vice versa.
The concrete Keychain calls are implemented in
internal/keychain/keychain_store.go as thin wrappers over
security(1). Entries created here are indistinguishable from those
created by the original shell helpers.
Development
Layout
secrets/
├── cmd/
│ └── secret/ # tiny main package; entry point
└── internal/
├── cli/ # cobra command tree (root, set, get, list, delete, help)
└── keychain/ # Store interface + naming helpers (backend impl comes later)
internal/ keeps the CLI and backend unimportable by third parties —
the CLI is a product surface, not a library.
Prerequisites
- Go 1.23+
just—brew install justgolangci-lint—brew install golangci-lintgotestsum—brew install gotestsum(per-package∅/✓/✗summary output, prints full failure detail on failing tests)
Common tasks
just # list available recipes
just all # lint + test + build
just build # compile ./secret
just test # gotestsum ./... (per-package summary, cache disabled)
just test [pkg] # gotestsum on a specific package, e.g. just test ./internal/cli
just test-race # gotestsum ./... -race
just cover # gotestsum with coverage + HTML report
just lint # golangci-lint run ./...
just lint-fix # golangci-lint run ./... --fix
just tidy # go mod tidy
just clean # remove ./secret and coverage.out
Version injection
version, commit and date are baked into the binary via
-ldflags by the justfile. When you build with a plain
go build/go install, they default to dev/none/unknown.
Values come from git at build time:
version=git describe --tags --always --dirtycommit=git rev-parse --short HEADdate= current UTC timestamp
Release flow
The three variables populate automatically — the order matters:
- Commit all pending changes (working tree must be clean).
- Tag the commit:
git tag -a v0.2.0 -m "v0.2.0". - Build:
just build(orjust install).
Result: secret -v prints v0.2.0 (commit abc1234, built …).
Building at other times still self-identifies honestly:
| Working tree state at build | secret -v shows |
|---|---|
| Clean, HEAD is at a tag | v0.2.0 (…) |
| Clean, no tag on HEAD | abc1234 (…) |
| Uncommitted changes present | abc1234-dirty (…) |
Built without just (no -ldflags) |
dev (commit none, …) |
Sanity-check before shipping a tagged build:
git status # must be clean
git describe --tags --always --dirty # must print exactly the tag
just build
./secret -v # must show the tag
Design notes
- Consumer-defined store interface. The
storeinterface lives ininternal/cli(where it is consumed), not ininternal/keychain(where the concrete type lives). This keeps the keychain package free of abstractions it doesn't need, while still letting tests inject a fake. context.Contexton every backend call, ready for cancellation and timeouts once we ever need them.RunE, notRun, so cobra propagates errors and the process exits non-zero on failure.- Testify (
assert+require) for readable, table-driven tests. SilenceUsage/SilenceErrorson the root command so error output stays a single clean line prefixed withsecret:.
Roadmap
- Step #3 — richer
listoutput (grouping by authority, JSON). - Step #4 — dynamic completion of credential names for
get/delete. - Step #5 — release automation (goreleaser, Homebrew tap).
License
MIT.