Leo is a CLI sidekick that lets you scaffold and quickly create custom commands and run them through a unified interface. It's highly configurable and ships with a JSON data store accessible from the same interface.
Out of the box Leo gives you a little JSON store to stash whatever you want and
a powerful "just copy this to my clipboard" command for the Mac, an extensible
ecosystem with the ability to organize the plugins into workspaces. Your existing
scripts are compatible with Leo as long as they're either in your PATH or
in Leo's command directory and prefixed with leo-.
Over the years I've written a ton of helpful scripts for myself and I usually throw them all into my home directory and invoke them through shell functions. This can get out of hand real quick and maintaining them has included manual efforts from my side. At first, I tried using various task runners and they usually tend to come with their own baggage and none of them have any storage. I see myself using this storage to store random configs or values. This gets out of hand really quickly. I wrote myself a simple snippet storage CLI and after using it for years on a daily basis, I figured it'd be a good addition to Leo. So, Leo is extensible and ships with a data store. The cool part about the storage is that it's a simple JSON, but if you give it a path to a file, it'll not just copy the path, but it actually copies the file to your clipboard, ready to paste into Slack, Telegram, Teams, or even Finder.
brew install --cask stealthfactory/leo/leoHomebrew adds the tap automatically and trusts the Leo cask when you install
it by its full name. No separate brew tap or brew trust step is needed.
To upgrade later:
brew update
brew upgrade --cask leoleo kv foo "bar" # save a value
leo kv foo # -> bar
leo kv foo "baz" # asks before replacing bar
leo kv -d foo # delete it
leo kv limits '{"cpu":2,"mem":"4Gi"}'
leo kv --query '.cpu' limits # -> 2
leo kv --secret api.token "s3cr3t" # listed as SECRET
leo kv -s api # search, best matches first
leo generate hello # scaffold your first subcommand
leo hello world # ...and run it
leo help # see everything, yours includedkv is an alias for store. Pick whichever reads better to you; they share the
same data and flags. You can set a value with leo kv and read it back with
leo store.
- Help and version
- Store (
store,kv) - Clipboard (
clip) - Generate (
generate,gen,new) - Configuration (
config) - Workspaces and external commands
- Environment variables
Here is the full built-in command tree. Aliases work anywhere the corresponding command name does, including in help requests.
| Command | Aliases | What it does |
|---|---|---|
leo store |
leo kv |
Print all keys and values. |
leo store <key> |
leo kv <key> |
Read a value, optionally with a query. |
leo store <key> <value> |
leo kv <key> <value> |
Save a value; asks before replacing one. |
leo store --secret <key> <value> |
leo kv --secret <key> <value> |
Save a value that lists as SECRET. |
leo store -s <text> |
leo kv -s <text> |
Search keys and values, best matches first. |
leo store -d <key> |
leo kv -d <key> |
Remove one key. |
leo clip <key-or-path>... |
β | Copy values or files to the macOS clipboard. |
leo generate <name> |
leo gen, leo new |
Create a script in a workspace. |
leo config |
β | Show help for configuration commands. |
leo config show |
β | Print the effective configuration. |
leo config path |
β | Print the config file location. |
leo config setup |
β | Walk through configuration interactively. |
leo version |
β | Print the installed version. |
leo help [command...] |
β | Show general help or help for a command. |
In the usage examples, <key> means a required argument, [value] means an
optional one, and ... means you can supply more than one. Leave the angle and
square brackets out when you type the command.
Run leo on its own, leo help, or leo --help to see the available commands.
External commands appear under their workspace names. For a particular command:
leo help kv
leo kv --help
leo generate -hEvery built-in accepts -h / --help. The root command also accepts -v /
--version; leo version prints the version too. There are no other global
flags. Leo does not install shell completion or expose a completion command.
To find a store key without TAB completion, search
for it with leo kv -s.
Flags belong to the command shown below and go before its arguments, as in
leo new --lang zsh foo-bar. Leo also accepts flags after the arguments, so
leo new foo-bar --lang zsh works too. Long flags with values accept either
--query '.cpu' or --query='.cpu'. Boolean flags default to off; adding one,
such as --compact, turns it on. Use -- to end flag parsing when an argument
starts with a dash:
leo kv -- temperature -5
leo kv --string -- option --verboseSuccessful built-in commands exit with status 0. Errors exit with status 1
and a message on stderr. Normal output goes to stdout, so you can pipe it or
capture it in a script.
Leo keeps its store in ~/.config/leo/store.json by default. It's a plain JSON
object, with your keys at the top level. Writes replace the file atomically,
format the JSON with sorted keys, and set file permissions to 0600 (read and
write for your user). See configuration to move it.
Once you have secrets, Leo also keeps a reserved $leo
key in the same file to remember which keys they are.
leo kv [flags] [<key> [<value>]]
What leo kv does depends on how many arguments you give it:
| You type | What happens |
|---|---|
leo kv |
Print every key and value. |
leo kv <key> |
Print one value. |
leo kv <key> <value> |
Save a value, asking before replacing an existing one. |
leo kv --secret <key> <value> |
Save a value that lists as SECRET. |
leo kv -s <text> |
Search keys and values, best matches first. |
leo kv -d <key> |
Delete a key. |
Keys are literal, case-sensitive strings. A key such as deploy.host is one
key, not a path into a nested object. Quote keys containing spaces. Any name
works as a key, including list or help, except $leo, which Leo reserves
for its own data. Single-quote it in the shell: '$leo'.
Each flag applies only to some of these actions, for example --string only
when saving a value. Passing a flag that doesn't apply is an error, so a typo
never gets silently ignored.
Save a new value or replace an existing one. Leo recognizes valid JSON, so
numbers, booleans, arrays, objects, and null keep their types. Anything that
isn't valid JSON becomes a string.
leo kv greeting "hello there" # string
leo kv retries 5 # number
leo kv enabled true # boolean
leo kv fallback null # null
leo kv hosts '["api","worker"]' # array
leo kv limits '{"cpu":2,"mem":"4Gi"}' # object
leo kv homepage https://example.com # string| Flag | Short form | Default | Meaning |
|---|---|---|---|
--string |
β | Off | Store the input literally as a string, even if it looks like JSON. |
--json |
β | Off | Require valid JSON; reject invalid input instead of storing a string. |
--file <path> |
β | Unset | Read the value from a file. |
--force |
-f |
Off | Replace an existing value without asking. |
--secret |
β | Off | Mark the value secret. |
--string and --json are mutually exclusive. Shell quotes group arguments;
they don't force Leo to store a string. For example, "42" reaches Leo as 42
and becomes a number. Use --string or pass JSON string quotes with '"42"'
when you want a string.
Instead of a value, you can use --file or a - to read stdin:
leo kv --string zip 7001
leo kv --json --file ./limits.json limits
printf '%s\n' '{"cpu":2}' | leo kv --json limits -
printf '%s' 'a multiline note' | leo kv --string note -File input takes precedence if you also supply a value. File contents are read
as-is; stdin input has trailing newline characters removed. Piping input alone
isn't enough: include the -. To store a literal single dash, read it from a
file or pass it as a JSON string: leo kv dash '"-"'.
The confirmation includes the stored type, for example set foo (string).
Replacing a key also prints its previous value. Files and URLs supplied as
ordinary values are just strings; use --file when you want the file's contents.
For images and other large files, storing their path keeps the JSON store small.
Saving to a key that already exists shows the current value and asks first:
$ leo kv foo baz
foo already exists: "bar"
update it to "baz"? [y/N]: y
set foo (string); previous value: "bar"
Anything other than y or yes keeps the old value and prints foo unchanged.
Pass -f to replace without asking. Saving the value a key already holds changes
nothing and doesn't ask. For a secret, the prompt shows SECRET in place of
both values.
Leo only asks when stdin is a terminal. In scripts and pipes, or when the value
itself comes from stdin with -, replacing an existing key without -f is an
error. That way a script never hangs waiting for an answer.
Read one value. A top-level string prints without quotes, ready to use in a shell script. Other values print as indented JSON.
leo kv greeting # hello there
leo kv limits # indented object
leo kv -c limits # {"cpu":2,"mem":"4Gi"}
leo kv --query '.cpu' limits # 2
leo kv --query '.mem' limits # "4Gi"
leo kv -r --query '.mem' limits # 4Gi
leo kv -r --query type limits # object| Flag | Short form | Default | Meaning |
|---|---|---|---|
--query <expression> |
β | Unset | Run a jq expression against this value. |
--raw |
-r |
Off | Print string query results without JSON quotes. |
--compact |
-c |
Off | Print each JSON result on one line. |
Queries run inside Leo using the Rust jaq engine; you don't need a separate jq executable.
Quote expressions so the shell doesn't interpret their punctuation. A query
can return several results, and Leo prints each separately:
leo kv -r --query '.[]' hosts
# api
# workerYou can combine -r and -c as -rc. Raw output takes precedence for string
results; compact output applies to the remaining JSON results. Without a query,
strings are already unquoted, so -r doesn't change them. To get a JSON-quoted
string, use --query '.' without -r. To see a value's JSON type (object,
array, string, number, boolean, or null), query type as shown above.
A missing key is an error. A stored null is a real value and prints null.
A secret prints its real value, so scripts can use it.
Print every key and value, sorted by key. Each line contains the key, a tab, and the full value as compact JSON. Strings stay quoted. An empty or not-yet-created store produces no output.
| Flag | Default | Meaning |
|---|---|---|
--query <expression> |
Unset | Run a jq expression against each value separately. |
leo kv
leo kv --query 'type' # show each key and its JSON type
leo kv --query 'select(type == "object")' # only object valuesThe query receives one stored value at a time, not the entire store. A query
that produces multiple results prints the key on each line. Queries that produce
no results omit that key. Invalid queries or runtime query errors fail the
command. Secrets always print as one key<TAB>SECRET line; queries don't run
on them.
Find keys by typing part of them. Search ignores case and prints the best
matches first, one key<TAB>preview line each. The preview is the value as
compact JSON, shortened to 80 bytes, including the trailing .... Leo ranks
matches in this order:
- The key is the text:
api. - The key starts with the text:
api.token,apiary. - A word inside the key starts with the text. Words start after
.,-,_,/,:,@,#, a space, or at a camelCase hump:github.api,awsApiKey. - The key contains the text:
rapid. - The key contains the text's letters in order:
dhfindsdeploy.host. - The value contains the text:
hosts, holding["api","worker"].
Within a group, earlier matches come first (for letters in order, the tightest match), then keys sort alphabetically.
leo kv -s api
leo kv -s dh # deploy.host
leo kv -s --query '.cpu' limits # project each match| Flag | Short form | Default | Meaning |
|---|---|---|---|
--search |
-s |
Off | Search for the argument instead of reading it as a key. |
--query <expression> |
β | Unset | Project each matching value through a jq expression. |
-s takes exactly one search term; quote it if it contains spaces. --query
runs after the match; it doesn't decide which keys match. Each query result gets
its own key<TAB>result line, with full compact JSON instead of a shortened
preview. Secret keys match by name only: Leo never searches their values, and
they print as SECRET. No matches means no output and a successful exit.
Leo can't offer TAB completion for keys without shell setup: zsh handles TAB itself, before Leo runs. Searching with the first few letters is the zero-setup way to find a key.
Remove one key and print deleted <key>. The deletion happens immediately,
without a confirmation prompt. A missing key is an error. The long form is
--delete.
leo kv -d greetingMark a value secret to keep it off your screen. Listings, search results,
replacement prompts, and confirmations show SECRET instead of the value.
Reading the key with leo kv <key> or copying it with leo clip <key> still
gives you the real value.
leo kv --secret api.token "s3cr3t"
pbpaste | leo kv --secret api.token - # keeps the secret out of shell history
leo kv # api.token SECRET
leo kv api.token # s3cr3t
curl -H "Authorization: Bearer $(leo kv api.token)" https://example.comThe confirmation shows the type as (string, secret). A secret stays secret
when you update it, even without --secret. To mark or unmark an existing key
without retyping its value, leave the value out:
leo kv --secret api.token # api.token is now secret
leo kv --secret=false api.token # api.token is no longer secretWith a value, --secret=false saves it as a regular value. Listing and search
never run --query on a secret, since a query's results or errors could reveal
it; each secret prints one key<TAB>SECRET line instead.
Secrets are masked, not encrypted. store.json holds them in plain text with
0600 permissions, and scripts that read $LEO_STORE directly see them. Leo
records which keys are secret under a reserved $leo key in the same file:
{
"$leo": {
"secrets": ["api.token"]
},
"api.token": "s3cr3t",
"greeting": "hello there"
}Values keep their normal shape, and a value and its secret mark are always saved
in the same write. Copying or backing up store.json keeps your secrets marked.
$leo never appears in listings, search results, or clip, and you can't set,
mark, or delete it with leo kv. Leo removes it when no secrets remain. Scripts
that read $LEO_STORE with jq can drop it with jq 'del(.["$leo"])'. If
$leo isn't in the expected shape, Leo stops with an error rather than show
your secrets.
leo clip [--file | --text] [--pretty] <key-or-path>...
clip uses the macOS clipboard. Give it a store key or an existing path. Leo
looks for a store key first, then a path on disk. A stored string is unquoted;
other stored values become compact JSON.
If every resolved argument is an existing path, Leo copies file objects, ready to paste into Finder or an app that accepts files. Otherwise, it copies text, joining multiple values with newlines. Image files also include image data on the clipboard for apps that support pasting images.
leo kv logo "$PWD/logo.png" # assuming logo.png exists
leo clip logo # copy the file named by the value
leo clip ./report.pdf ./chart.png # copy two existing files
leo clip limits # copy JSON text
leo clip --pretty limits # copy indented JSON
leo clip --text logo # copy the stored path as text
leo clip --text "hello there" # copy literal text| Flag | Default | Meaning |
|---|---|---|
--file |
Off | Force file-object mode; every resolved path must exist. |
--text |
Off | Force text mode; allow unmatched arguments as literal text. |
--pretty |
Off | Indent stored JSON when copying it as text. |
--file and --text are mutually exclusive. Both still resolve store keys
first; --text changes the copy mode, not the lookup order. Text mode copies a
file's path, not its contents. File mode expands a leading ~ and resolves
relative paths from your current directory.
Without --text or --file, an argument that matches neither a key nor an
existing path is an error, and nothing is copied. This helps catch mistyped key
names. The command reports either copied text to clipboard or the number of
files copied. On other operating systems, clip returns an unsupported-platform
error.
leo generate [--lang <language>] [--workspace <name>] [--force] <name>
Generate a small working script, edit it, and run it through Leo. gen and new
are aliases for generate:
leo generate hello
leo gen --lang python --workspace work deploy
leo new --lang ts build
leo hello world| Flag | Default | Meaning |
|---|---|---|
--lang <language> |
Configured gen_lang, or bash |
Choose the script template. |
--workspace <name> |
First workspace in search order | Choose the destination by workspace name. |
--force |
Off | Overwrite the target script if it already exists. |
The name must start with an ASCII letter or digit. The remaining characters can
be letters, digits, hyphens, or underscores. Use hello, not leo-hello: Leo
adds the prefix. Running leo generate without a name shows help.
| Language | Accepted shorthand | Generated filename | Interpreter |
|---|---|---|---|
bash |
sh |
leo-<name> |
bash |
zsh |
β | leo-<name> |
zsh |
python |
py |
leo-<name> |
python3 |
node |
js, javascript |
leo-<name> |
node |
typescript |
ts |
leo-<name>.mts |
node with native TypeScript support |
Language names are lowercase. The templates use /usr/bin/env, so the relevant
interpreter must be on your PATH. The TypeScript template uses erasable type
annotations and runs directly with Node; it doesn't invoke tsx or ts-node.
Leo creates the destination directory if needed and writes the script with
executable permissions. It refuses to replace an existing target unless you pass
--force. The output tells you where the file was written and how to run it.
--workspace only chooses where to write the file. All workspaces remain active
when running commands. If LEO_PATH is set, its first directory is also the
default generation destination; choose a named workspace explicitly when you
want a different one.
Leo works without a config file. Run leo config for help with its three
subcommands. They take no arguments or flags other than -h / --help.
Print the config file location, store location, default generation language, and
workspaces in search order. Paths have ~ and environment variables expanded.
This is the place to check when Leo is looking in a different directory than you
expected. Directories searched through the system PATH aren't listed here.
Print only the config file location, even if the file hasn't been created yet:
leo config path
# /Users/you/.config/leo/config.tomlWalk through the store path, default generation language, and existing workspace
paths, then offer to add more named workspaces. Press Enter to keep the value
shown. New workspaces default to <Leo config directory>/<name>-commands.
Setup validates the language, creates the config's parent directory if needed,
and writes a new TOML file. It rewrites the configuration, so custom comments
won't be preserved. Workspaces added through LEO_PATH are temporary and aren't
written into the file by setup.
A workspace is a named directory of commands. The default is
~/.config/leo/commands. Keep work and personal scripts together or split them
into directories:
# ~/.config/leo/config.toml
store_path = "~/.config/leo/store.json"
gen_lang = "bash"
[[workspace]]
name = "default"
path = "~/.config/leo/commands"
[[workspace]]
name = "work"
path = "~/work/leo-commands"All settings are optional. Without store_path, Leo uses store.json in its
base config directory. Without gen_lang, it uses bash. Without workspace
entries, it includes the default commands directory. An unnamed workspace is
called default. Paths expand a leading ~ and environment variables; use
absolute paths or ~/... to keep them independent of your current directory.
Leo scans directories in this order:
- Directories in
LEO_PATH, from left to right, grouped under[env]in help. - Workspaces from the config file, in the order written (or the default workspace).
- Directories on the system
PATH, grouped under[$PATH]in help.
The first command with a given name wins. Built-ins and their aliases always
win, so scripts named leo-store, leo-kv, leo-gen, or leo-help can't replace
those commands. Duplicate workspace paths are included only once. Directories
that can't be read are skipped, and scanning doesn't recurse into subdirectories.
To bring an existing script, name it leo-<name>, give it a suitable shebang,
make it executable, and put it in one of those directories. You can also keep a
recognized file extension; Leo strips it from the command name:
| Script filenames | Leo command |
|---|---|
leo-deploy, leo-deploy.sh, leo-deploy.bash, leo-deploy.zsh |
leo deploy |
leo-report.py |
leo report |
leo-build.js, leo-build.mjs, leo-build.cjs |
leo build |
leo-check.ts, leo-check.mts, leo-check.cts |
leo check |
Other extensions are ignored. The extension controls discovery; the script's
shebang controls how it runs. Leo discovers matching filenames even before
checking whether they can execute, so remember chmod +x for scripts you add by
hand.
Add a # leo: or // leo: comment within the first 20 lines to give the command
a one-line description in leo help:
#!/usr/bin/env bash
# leo: print the saved deployment host
"$LEO_BIN" kv deploy.hostExternal commands run from your current directory and receive their arguments,
stdin, stdout, and stderr directly. Leo forwards their exit status. On terminal
Ctrl-C, Leo waits for the script to stop: a script that handles the interrupt
can exit successfully, while an unhandled SIGINT returns status 130. Flags after
an external command, including --help, go to the script itself; each script
owns its own flags. Generated templates handle -h and --help as their first
argument. leo help <name> shows Leo's entry for the command without running it.
These variables affect Leo itself:
| Variable | Effect |
|---|---|
XDG_CONFIG_HOME |
Sets the parent of Leo's base config directory. When unset or empty, Leo uses ~/.config; otherwise it uses $XDG_CONFIG_HOME/leo as its base. |
LEO_CONFIG |
Selects the TOML file to read and write, instead of <base>/config.toml. It does not move the default store or workspace. |
LEO_PATH |
Adds workspace directories ahead of the configured ones. Use the platform path separator (: on macOS/Linux). |
PATH |
Supplies the final directories searched for leo-* commands and the interpreters used by scripts. |
For example, to add a directory for one invocation:
LEO_PATH="$PWD/scripts" leo help
LEO_CONFIG="$PWD/leo.toml" leo config showWhen Leo runs an external command, it also sets these variables for the child:
| Variable | Value |
|---|---|
LEO_BIN |
Path to the running Leo executable. Use it to call Leo from a script. |
LEO_STORE |
Effective store file path. This is information for your script, not a store-path override read by Leo. |
LEO_CONFIG |
Effective config file path, so calls back into Leo use the same config. |
LEO_WORKSPACE_PATHS |
Workspace paths in search order, separated by the platform path separator. It excludes directories discovered only through PATH. |
The directory containing LEO_BIN is prepended to the child's PATH, so a plain
leo kv foo inside your script uses the same installation too.
Leo is written in Rust. Install the toolchain with rustup,
then run make build to create ./leo. make install installs it in
~/.local/bin; make run ARGS="kv" runs it from source.
make check runs formatting, Clippy, Rust tests, and a compatibility suite
using recorded expectations in tests/fixtures/cli.json. The compatibility
suite needs Python 3. It uses disposable homes and checks command output, exit
status, stored data, permissions, generated scripts, clipboard requests, and
signals. Building, testing, and releasing Leo do not require a Go toolchain.
See tests/fixtures/README.md for the baseline's
provenance and instructions for updating expectations.
The query layer includes compatibility definitions and small patches to
jaq-core and jaq-json for the previous engine's object traversal,
assignment, number behavior, and errors. Run the compatibility suite when
updating those dependencies.
On macOS, python3 tests/native_clipboard.py ./leo also tests the real clipboard
with text, multiple files, and image data, then restores its original contents.
Leo is distributed as signed and notarized macOS binaries through GitHub
Releases and the
stealthfactory/homebrew-leo
tap. GoReleaser builds Apple Silicon and Intel archives, publishes their
checksums, and updates Casks/leo.rb in the tap.
Before the first release, configure these repository secrets under StealthFactory/leo β Settings β Secrets and variables β Actions β New repository secret:
HOMEBREW_TAP_TOKENMACOS_SIGN_P12MACOS_SIGN_PASSWORDMACOS_NOTARY_KEYMACOS_NOTARY_KEY_IDMACOS_NOTARY_ISSUER_ID
Create HOMEBREW_TAP_TOKEN at GitHub β Settings β Developer settings β
Personal access tokens β Fine-grained tokens β Generate new token. Set the
resource owner to StealthFactory, select only the homebrew-leo repository,
and grant Repository permissions β Contents: Read and write. No account or
organization permissions are needed. Copy the token immediately and save it as
the HOMEBREW_TAP_TOKEN repository secret in StealthFactory/leo, not in the
tap repository.
The remaining values require a paid Apple Developer Program membership:
- In the Apple Developer portal, create a Developer ID Application
certificate. Import its
.cerfile into Keychain Access, export the certificate and private key as a password-protected.p12, and save its password asMACOS_SIGN_PASSWORD. - In App Store Connect, open Users and Access β Integrations β App Store
Connect API, create a team API key, and download the
.p8file. Save the key ID asMACOS_NOTARY_KEY_IDand the issuer ID asMACOS_NOTARY_ISSUER_ID. Apple allows the.p8file to be downloaded only once. - Base64-encode both files on macOS without line wrapping:
Save the clipboard value as
base64 -i DeveloperIDApplication.p12 | tr -d '\n' | pbcopy
MACOS_SIGN_P12, then repeat for the.p8file and save it asMACOS_NOTARY_KEY.
Keep the original certificate, private key, .p12, password, and .p8 file in
a secure credential store. Never add them to either Git repository. See
GoReleaser's notarization documentation
for additional background.
To check the build locally without signing, notarizing, or publishing:
brew install goreleaser
make release-checkTo publish vX.Y.Z, commit the release, then create and push its tag:
git tag -a vX.Y.Z -m "leo vX.Y.Z"
git push origin vX.Y.ZThe Release workflow tests Leo, signs and notarizes both binaries, creates the
GitHub Release, publishes the cask, installs and smoke-tests it, and then removes
the legacy source formula from the tap. The formula remains available until
that first cask release succeeds, so a failed migration does not break current
installs.
Leo is a personal tool, built to stay small and get out of the way. Add the commands you wish your shell had, and make it yours.
A Stealth Factory make -
MIT