A small tool to pin commands you use often and pick them from a searchable menu. It's like shell history, but only holds what you chose to keep. Pins live in a local SQLite database, and you can sync them between your devices end-to-end encrypted, so the sync server can't read or change them.
- Searchable menu: run, copy (
-c) or print (-p) a pin. Search matches commands, labels and tags. - Labels and tags:
-n "tail prod logs" -t k8s,prod. Filter the menu withpinner -t k8s. - Placeholders:
ssh <<user>>@<<host>> -p <<port:22>>asks for values when you run the pin, remembers what you used last, and can save new values as defaults or as a new pin. - Smart ordering: the pins you use most, and used most recently, come first.
- Sensitive pins:
--sensitivemasks a command in menus and lists (good for commands with tokens). - Shell integration: Alt+p puts a pin on your prompt so you can edit it before running it, and
pin-lastpins the command you just ran. - Editor support:
--editwrites or edits multi-line commands in$EDITOR. - Import and export as JSON.
- Encrypted sync between devices through a server you host (see Sync).
- Pure Go, no cgo: one static binary for Linux, macOS and Windows.
pinner [flags] open the menu and run the selected pin
pinner add [command...] pin a command
pinner list list pins
pinner update [id] change a pin
pinner remove [id] remove a pin (alias: rm)
pinner export / import back up or restore pins as JSON
pinner init <bash|zsh|fish> print shell integration
pinner sync ... set up and run encrypted sync
Menu flags:
-c, --copy copy the command to your clipboard instead of running it
-p, --print print the command to stdout instead of running it
-t, --tag only show pins with this tag
--confirm show the final command and ask before running it (or set PINNER_CONFIRM=1)
# Pick a pin and run it. Pinner exits with the command's exit status.
pinner
# Copy, or print for use in scripts
pinner -c
eval "$(pinner -p)"
# Add pins: as arguments, piped in, from $EDITOR, or from your history
pinner add ls -la
echo "ls -la" | pinner add
pinner add --edit
pinner add --last
# Labels, tags and flags go before the command (use -- when the command starts with a dash)
pinner add -n "worker logs" -t k8s,prod -- kubectl logs -l app=worker -f -n platform
# Placeholders: <<name>> or <<name:default>>
pinner add -n tunnel -- 'ssh -i ~/.ssh/id_ed25519 <<user>>@<<host>> -L <<port:5601>>:localhost:<<port>>'
# Sensitive pins are masked as "curl ••••••" in the menu and in `pinner list`
pinner add --sensitive -n "search api" -- curl -H "X-API-KEY: ..." https://api.example.com
pinner list --show-sensitive
# Change or remove a specific pin (IDs are shown by `pinner list`)
pinner update 4 --label "db shell" --tag db
pinner update 4 --edit
pinner rm 4 -y
# Back up and restore
pinner export -o pins.json
pinner import pins.jsonWhen you run a pin with placeholders, pinner asks for each value. A placeholder used more than once is asked for once:
host: staging.internal
port [5601]: ← Enter keeps the value shown in brackets
? You entered new values:
▸ Run
Run and save as defaults → the pin becomes ssh <<host:staging.internal>> -L <<port:5601>>…
Save as new pin, then run → a separate pin with these values filled in (asks for a label)
- Last values are remembered. Next time, each prompt is pre-filled with what you used last on this device, so pressing Enter repeats the last run. These values stay on the device and aren't synced.
- You're only asked about saving when you type something new. Repeating the last values, or accepting
the defaults, runs straight away. Set
PINNER_ASK_SAVE=0to never be asked; values are still remembered. - Saving as defaults edits the pin, so the new defaults sync to your other devices. Saving as a new pin copies the original's tags and its sensitive and local settings.
- Sensitive pins never remember values (they're often tokens), and their defaults show as
[default]instead of being printed.
Placeholders use <<name>> because {{...}} shows up in real commands (docker --format, kubectl go-templates).
A command with no <<name>> placeholders is never changed. Values are inserted exactly as typed, so quote them
yourself if they contain spaces or shell characters. Defaults can't contain <, > or newlines.
Add one of these to your shell's startup file:
eval "$(pinner init bash)" # ~/.bashrc
eval "$(pinner init zsh)" # ~/.zshrc
pinner init fish | source # ~/.config/fish/config.fish- Alt+p opens the menu and inserts the chosen pin at your cursor without running it. You can edit it first,
and it lands in your shell history as a normal command. This also avoids a problem with running pins directly:
the output of
pinner | tee fileincluded pinner's menu. pin-last [add flags]pins the command you just ran, e.g.pin-last -n "rebuild" -t dev.
pinner add --last reads your history file instead. Bash only writes that file when a shell exits, so
pin-last is more reliable.
The bash integration is covered by automated tests. The zsh and fish scripts are syntax-checked only when those shells are installed.
Selecting a pin runs it with $SHELL -c. Pins are commands you saved yourself, but anything that can
change your pins can run commands as you. Pinner protects that in three ways:
- The database is only readable by you: mode 0600 in a 0700 directory.
- Synced pins are authenticated, so the server can't inject or alter them.
--confirm(orPINNER_CONFIRM=1) shows the final command before running it.
Sync works like hiSHtory: your first device creates a random secret key. Every other key is derived from it, and pins are encrypted on your device before they're uploaded. The server only stores ciphertext under opaque IDs. It can't read your pins, and it can't forge or alter one, because tampered records are rejected and never applied.
# On your first device
pinner sync init --server https://pins.example.com
# prints your secret: PINR-XXXX-XXXX-...
# On each other device
pinner sync join PINR-XXXX-XXXX-... --server https://pins.example.com
pinner sync # sync now (also happens automatically, see below)
pinner sync status # server, pending changes, local-only pins
pinner sync key # show the secret again
pinner sync leave # stop syncing on this device (add --purge to delete your data from the server)- Opening the menu pulls changes, waiting at most 1.5s. Adding, updating or removing a pin pushes it, waiting at most 3s. If the server can't be reached you see a one-line warning and pinner keeps working offline. Changes sync the next time it can connect.
pinner add --local ...orpinner update <id> --localkeeps a pin on this device only. Making a synced pin local removes it from your other devices.- Usage counts are per device and aren't synced.
- When the same pin is edited on two devices, the later edit wins.
- Keep the secret safe. Anyone with it can read and change your pins. It is stored in
~/.config/pinner/sync.json(mode 0600). If you lose it and all your devices, the synced copy can't be recovered. - The server rejects plain
http://URLs except for localhost, because the access token would travel unencrypted. You can override that withPINNER_ALLOW_INSECURE=1if you understand the risk. SetPINNER_SYNC=offto disable automatic syncing.
The design and threat model are in docs/sync-design.md. To run your own server, see docs/self-hosting.md.
Releases have prebuilt binaries for Linux (amd64, arm64, armv7, 386, riscv64), macOS (Intel and Apple
Silicon), Windows (amd64, arm64) and FreeBSD. The install script picks the right one, checks its SHA-256
checksum against the release's checksums.txt and installs it to /usr/local/bin (using sudo if needed):
curl -fsSL https://raw.githubusercontent.com/osean-man/pinner/master/install.sh | shOptions: PINNER_VERSION=v1.0.0 to install a specific release, BIN_DIR=~/.local/bin to install elsewhere, and
PINNER_VERIFY_ATTESTATION=1 to also check the build provenance with the GitHub CLI.
Updating works the same way: run the install script again. Check what you're running with pinner --version;
it prints the release version and the exact commit it was built from.
Every release includes checksums.txt (SHA-256 for every archive), an SPDX SBOM per archive, and a signed
build provenance attestation
proving the archive was built by this repository's release workflow from the tagged commit:
sha256sum --ignore-missing -c checksums.txt
gh attestation verify pinner_1.0.0_linux_amd64.tar.gz --repo osean-man/pinnerBuilds are reproducible: the same tag built with the same Go toolchain gives byte-identical binaries.
Go 1.26+ is required; there's no C compiler dependency. If your Go is older than the toolchain line in go.mod,
Go downloads that version automatically.
git clone https://github.com/osean-man/pinner.git
cd pinner
make install # builds and copies to /usr/local/bin (sudo make install if you don't own it)
make install BINDIR=~/.local/bingo build on its own only writes ./pinner in the current directory; it doesn't install anything. Cross-compile
with GOOS/GOARCH, e.g. GOOS=windows GOARCH=amd64 go build -o pinner.exe .. The sync server builds with
make server.
Everything lives in ~/.config/pinner/ (override with PINNER_CONFIG_DIR):
| File | Contents |
|---|---|
commands.db |
your pins (SQLite) |
commands.db.bak |
copy of your database taken before an upgrade changed its format |
sync.json |
sync server URL and secret (only if sync is set up) |
Upgrading from an earlier version migrates your existing database automatically, after making the backup above.
make test # go test -race ./... (unit, sync and end-to-end tests)
make lint # gofmt, go vet, govulncheck
make snapshot # build every release artifact into dist/ without publishing (needs Docker)
go test -short ./... # skip the test that builds the original release to check migrationThe interactive end-to-end tests drive the real menu inside tmux and are skipped if tmux isn't installed.
- CI (
.github/workflows/ci.yml) runs on every push and pull request. It runs the tests on Linux (amd64 and arm64), macOS and Windows, checks formatting, runsgo vet, govulncheck and shellcheck, and does a full GoReleaser snapshot build plus a Docker build, so a broken release config fails CI before anything is tagged. - Releases (
.github/workflows/release.yml) run when av*tag is pushed. The full CI suite runs first. Then GoReleaser publishes the archives, checksums and SBOMs, the archives get a build provenance attestation, and a multi-arch (amd64/arm64)ghcr.io/osean-man/pinner-serverimage is pushed with its own SBOM and attestation. - Pinning: every GitHub Action is pinned to a full commit SHA, Docker base images are pinned by digest, the Go
toolchain is pinned in
go.mod, and GoReleaser and govulncheck run at fixed versions. Dependabot opens weekly grouped PRs to update the actions, Go modules and images.
To cut a release:
git tag -a v1.2.3 -m "v1.2.3" && git push origin v1.2.3