I've watched the up arrow fire in the dark, hunting through history for a command lost three weeks and a thousand keystrokes ago.
All those commands, scattered across gists, notes, and files no one will ever open again.
All those moments will be lost in time… like uncommitted changes before a force push.
Time to launch.
A tiny personal command launcher. cl stores a name -> shell command
dictionary in a JSON file and lets you search it interactively from
your terminal. By default the command itself stays hidden — only
names are shown — so you can run it without ever seeing its value.
Toggle Ctrl+S in the picker to also show each command under its
name in the list (display-only — Enter always runs the command
directly). All output from a run command reaches your console
normally.
Everything happens inside a single interactive picker — there's no separate "add"/"remove" subcommand to remember:
clopens the picker;cl <filter>opens it pre-filtered.- Type to filter by name (case-insensitive substring match — the
whole typed text has to appear together, not just its letters
scattered anywhere in the name), use the arrow keys to move,
Enterto pick,Esc/Ctrl-Cto cancel. Ctrl+Stoggles whether the list shows each command next to its name (persisted immediately, so it's remembered next time). Enter always runs the command directly — the toggle only affects what you see in the list, never what Enter does.Ctrl+Aadds a new command: it first asks for a name (spaces are allowed — there's no CLI token boundary to worry about anymore), then, onEnter, asks for the shell command itself and saves it immediately on the nextEnter. There's no editor involved at any point. A name that's already in use, or a name/command that's empty (after trimming whitespace), is rejected in place with an inline message so you can just try again.Ctrl+Eedits the highlighted command: its current value appears pre-filled and editable;Enterasks "save?" (ysaves,Esc/ndiscards the edit and leaves the stored command untouched).Ctrl+Rrenames the highlighted command: its current name appears pre-filled and editable (spaces are allowed);Enterasks "rename?" (ysaves,Esc/ndiscards the rename and leaves the stored name untouched). A name that's already used by another command is rejected in place with an inline message.Ctrl+Ddeletes the highlighted command, after ay/Nconfirmation.- A command can contain placeholders with the
{{name}}or{{name:default}}syntax. When you pick such a command,clprompts you to fill each placeholder before running it — see Placeholders below. In the list, commands with placeholders show a hint next to the name, e.g.ssh server (user, host)orgit push (remote[default:origin], branch[default:main]). - Commands are persisted as JSON in your user config directory
(
~/Library/Application Support/clon macOS,~/.local/share/clon Linux,%AppData%\clon Windows) — written to disk immediately as each add/edit/remove is confirmed, not just when quit.
cl.mp4
brew install spltek/tap/clcurl -fsSL https://raw.githubusercontent.com/spltek/cl/main/install.sh | shDownloads the right binary for your OS/arch from the
latest release into
~/.local/bin (override with CL_INSTALL_DIR).
iwr https://raw.githubusercontent.com/spltek/cl/main/install.ps1 -UseBasicParsing | iexInstalls into %LOCALAPPDATA%\cl\bin (override with CL_INSTALL_DIR).
The installer automatically adds the install directory to your PATH, so cl is ready to use immediately.
None of the above needs admin/elevated rights: installing into
~/.local/bin / %LOCALAPPDATA%, editing your own shell rc files,
and setting the User-scope PATH (as opposed to Machine-scope)
are all plain per-user operations. The installer also clears the
macOS quarantine flag defensively (harmless if absent, no sudo
needed since it's your own file).
If you are on Windows and your PowerShell execution policy resolves
to Restricted or AllSigned, you may need to allow scripts to run
with Set-ExecutionPolicy -Scope CurrentUser RemoteSigned before
the install command above can execute.
Requires Go 1.25+:
make install-localBuilds the binary and installs it to ~/.local/bin/cl.
At the end it prints the one command you need to
start using cl in the current shell without restarting it.
If you prefer to manage the binary yourself,
make buildcompiles intobin/clandmake installputs it into$GOPATH/bin.
brew uninstall clcurl -fsSL https://raw.githubusercontent.com/spltek/cl/main/uninstall.sh | shRemoves the binary and config directory (~/.local/bin/cl and ~/.local/share/cl or ~/Library/Application Support/cl).
iwr https://raw.githubusercontent.com/spltek/cl/main/uninstall.ps1 -UseBasicParsing | iexRemoves the binary, config directory (%APPDATA%\cl), and cleans up the install directory from your PATH.
Releases are built and published automatically by
.github/workflows/release.yml via
GoReleaser whenever a vX.Y.Z tag is pushed:
git tag v0.1.0
git push origin v0.1.0.github/workflows/ci.yml runs build/vet/test/gofmt on every push and
pull request (Linux, Windows, and macOS).
make build # compile into bin/cl
make test # run the test suite
make test-verbose # run the test suite with -v
make cover # run tests with coverage report
make vet # go vet
make fmt # gofmt -w .
make fmt-check # fail if any file is not gofmt-formatted
make run ARGS="foo" # build then run with the given args
make clean # remove bin/ and dist/Commands can contain {{name}} or {{name:default}} placeholders.
When you pick such a command, cl prompts you to fill each
placeholder in sequence before running the resolved command.
{{name}} required placeholder — must be filled in
{{name:default}} optional placeholder — pre-filled with default,
press Enter to accept it as-is
Placeholder names can contain letters, digits and underscores
(\w+). The default value can be any text that does not contain
}}.
In the list, placeholders appear as a hint after the command name:
ssh server (user, host)
git push (remote[default:origin], branch[default:main])
echo hi (name[default:pippo])
Required parameters are listed as bare names; parameters with a
default use name[default:value].
Add a command with a placeholder:
ctrl+a → name: "ssh server" → command: ssh {{user}}@{{host}}
Now pick it:
$ cl ssh
cl> ssh
> ssh server (user, host)
↑/↓ move enter run selected ...
# Press Enter — cl detects the {{placeholders}} and prompts:
ssh {{user}}@{{host}}
user:
_
enter continue · esc cancel
# Type a value, press Enter:
ssh admin@{{host}}
host:
_
enter run · esc cancel
# Type the host and press Enter — cl announces the name in pink
# with the resolved parameter values in gray, then runs the command:
> cl-exec: ssh server (admin, prod.example.com)
Last login: ...
With defaults:
ctrl+a → name: "git push" → command: git push {{remote:origin}} {{branch:main}}
Picking it pre-fills "origin" for remote and "main" for branch
— press Enter through both to accept the defaults, or type over them.
# Database connections
psql -h {{host:localhost}} -p {{port:5432}} -U {{user}} -d {{db:postgres}}
# SSH with numbered hosts
ssh {{user}}@prod-{{num:1}}.example.com
# Docker with configurable ports
docker run -p {{port:3000}}:3000 {{image:node:18}}
# Kubernetes with namespace
kubectl logs {{pod}} -n {{namespace:default}}
Placeholders work regardless of whether commands are shown or hidden
in the list. cl always resolves them and runs the command directly.
Some commands, like shell builtins, modify the current shell
environment and need a replacement shell after running to persist
their effects. Without && exec $SHELL the command runs in a
subshell and the changes are lost when it exits.
The most common commands that need this pattern:
| Command | Why it needs && exec $SHELL |
|---|---|
cd |
Changes directory only in the subshell, then is lost |
source |
Sources a file in a subshell, leaving your env unchanged |
. |
Same as source — the dot builtin |
export |
Sets env vars that disappear when the subshell ends |
alias |
Defines aliases that are lost when the subshell exits |
unset |
Unsets variables only in the subshell |
Example with cd:
ctrl+a → name: "go to project" → command: cd ~/projects/target-folder && exec $SHELL
When you pick this command, cl runs it and replaces the current
shell with a fresh one in the target directory — so the cd
actually persists.
Tip: You only need
&& exec $SHELLfor commands that change your shell state. Regular commands likegit,npm,docker,ssh, etc. work fine without it.
$ cl
cl>
no matching commands
↑/↓ move
enter run selected
ctrl+a add new command
ctrl+s command show toggle
esc cancel
# press ctrl+a, type a name, enter, type the command, enter:
Add command "build" - shell command:
npm run build -- --watch
enter save · esc cancel
# back at the list, command hidden (the default) - Enter runs it
# directly, with its own output printing normally, and you never
# see "npm run build -- --watch" itself:
$ cl bui
cl> bui
> build
↑/↓ move
enter run selected
ctrl+a add new command
ctrl+e edit selected
ctrl+r rename selected
ctrl+d delete selected
ctrl+s command show toggle
esc cancel
# press Enter on "build": cl announces the name (in pink, the same
# color used for the selected entry in the picker) before running
# it, then the command's own output follows normally
> cl-exec: build
...(build output)...
# press ctrl+s to show commands in the list (display-only —
# Enter always runs the command directly):
$ cl bui
cl> bui
> build
npm run build -- --watch
↑/↓ move
enter run selected
ctrl+a add new command
ctrl+e edit selected
ctrl+r rename selected
ctrl+d delete selected
ctrl+s command show toggle
esc cancel
# The command is visible below its name so you know what you're
# about to run. Enter always executes it directly.
# Long commands wrap:
$ cl
cl> deploy
> deploy
kubectl apply -f production/overlays/us-east-1/kustomization.yaml
--prune --selector app=api
↑/↓ move enter run selected ...
esc cancel
# press ctrl+e on "build" to edit it in place:
Edit "build":
npm run build -- --watch --fast
enter continue · esc cancel
# Enter then asks to confirm:
Save "build" -> npm run build -- --watch --fast ? [y/N]
y confirm · n/esc cancel
# press ctrl+r on "build" to rename it:
Rename "build":
release
enter continue · esc cancel
# Enter then asks to confirm:
Rename "build" -> "release" ? [y/N]
y confirm · n/esc cancel
# press ctrl+d on "release" to delete it:
Delete "release" (npm run build -- --watch --fast) ? [y/N]
y confirm · n/esc cancel
