A fast, extensible command palette for Linux. Launch apps, switch windows, control audio, manage clipboard, and more - all from a unified interface.
pal run fzf apps # launch applications
pal run rofi pals # pick a palette to run
pal run fzf combine # combined view of multiple palettes
- Builtin palettes - Apps, bookmarks, SSH hosts, processes, and more
- Builtin frontends - fzf, rofi, and stdin work out of the box
- Plugin system - Extend with bash, python, or any language
- Layered config - Defaults + user config + project config + env vars
- Icon support - XDG icons for rofi, UTF/Nerd Font icons for terminal frontends
- Combine palettes - Merge multiple palettes into one view
- Input palettes - Text input mode with live results (calculator, eval, etc.)
- Prompts - Ask for user input on pick, usable from plugins and standalone scripts
- Caching - Pre-computed display for fast startup on heavy palettes
cargo install rpalRequires Rust 1.70+ (rustup)
# Initialize config at ~/.config/pal/config.toml
pal init
# Run with default palette and frontend
pal
# Run specific palette with specific frontend
pal run rofi apps
# List items without frontend (useful for debugging)
pal list apps
# Prompt user for input
pal prompt '{"message": "Enter hostname"}'
# Run an action on a value
echo "hello" | pal action copy
# List installed remote plugins
pal plugins
# Update all remote plugins
pal update
# Show loaded configuration
pal show-config| Palette | Description |
|---|---|
apps |
List and launch desktop applications |
bookmarks |
Browser bookmarks (Firefox/Chrome) |
ssh |
SSH hosts from ~/.ssh/config |
pals |
List and run other palettes |
psg |
List and kill processes |
combine |
Combine multiple palettes into one |
| Frontend | Description |
|---|---|
fzf |
Terminal fuzzy finder |
rofi |
Desktop launcher with icons |
stdin |
Simple numbered list selection |
Config is loaded in order (later overrides earlier):
- Built-in defaults
pal.default.toml(in current directory)~/.config/pal/config.toml(user config)pal.toml(in current directory)-c <path>(CLI argument)PAL_*environment variables
[general]
default_palette = "combine"
default_frontend = "fzf"
[palette]
[palette.combine]
base = "builtin/palettes/combine"
icon = "view-grid"
include = ["pals", "quickcmds"]
[palette.quickcmds]
icon = "utilities-terminal"
auto_list = true
auto_pick = true
data = "~/.config/pal/commands.json"
default_action = "cmd"
action_key = "cmd"
[palette.audio]
base = "~/.config/pal/plugins/audio"
icon = "audio-card"For simple palettes, use a JSON lines file or a JSON array:
{"name": "List files", "icon": "terminal", "cmd": "ls -la"}
{"name": "Git status", "icon": "git", "cmd": "git status"}[
{"name": "List files", "icon": "terminal", "cmd": "ls -la"},
{"name": "Git status", "icon": "git", "cmd": "git status"}
]TOML is also supported. Use an array-of-tables with any key name:
[[items]]
name = "List files"
icon = "terminal"
cmd = "ls -la"
[[items]]
name = "Git status"
icon = "git"
cmd = "git status"Point data at a .toml file and pal will parse it automatically:
[palette.quickcmds]
auto_list = true
auto_pick = true
data = "~/.config/pal/commands.toml"
default_action = "cmd"
action_key = "cmd"Tip: For syntax highlighting of the
cmdfields, name your file with a compound extension likecommands.bash.toml. The zcag/nvim-dek plugin uses the inner extension to highlight embedded languages within TOML string values.
The id field is optional and defaults to name if missing.
Items and palettes support three icon types, used by different frontends:
| Field | Used by | Example |
|---|---|---|
icon_xdg |
rofi (freedesktop icon names) | utilities-terminal |
icon_utf |
fzf (UTF-8/Nerd Font glyphs) | |
icon |
Fallback for either | terminal |
Rofi prefers icon_xdg, fzf prefers icon_utf, both fall back to icon. Character icons (non-ASCII) are rendered inline, while XDG icon names are shown as images in rofi.
Set a palette-level icon in config, and it applies to all items that don't have their own:
[palette.cmds]
icon_xdg = "utilities-terminal"
icon_utf = ""Input palettes accept text input instead of filtering a static list. The query is passed to the plugin's list command via stdin, and the plugin returns items based on it.
[palette.calc]
base = "github:zcag/pal/plugins/palettes/calc"
input = true
input_prompt = "Calculate"| Field | Description |
|---|---|
input |
Enable text input mode |
input_prompt |
Custom prompt message (defaults to palette name) |
fzf reloads results live as you type using --bind change:reload. rofi uses script mode - type a query, press Enter to see results, then select. Other frontends use a two-step prompt then select flow.
The plugin's list command receives the query on stdin:
list() {
query=$(cat)
if [[ -z "$query" ]]; then
echo '{"name":"Type an expression..."}'
return
fi
result=$(qalc -t "$query" 2>/dev/null)
echo "{\"name\":\"$query = $result\",\"result\":\"$result\"}"
}Prompts let you collect user input before or during pick. There are two mechanisms:
Add a prompts array to any item. When the item is picked, each prompt is shown to the user via the active frontend. Collected values are substituted into {{key}} placeholders in all item fields and injected as PAL_<KEY> env vars.
{"name": "SSH Tunnel", "cmd": "ssh -L {{port}}:localhost:{{port}} {{host}}", "prompts": [
{"key": "host", "message": "Hostname"},
{"key": "port", "message": "Local port"}
]}This works in data files, plugin output, and through the combine palette.
| Type | Description | Extra fields |
|---|---|---|
text |
Free text input (default) | |
choice |
Select from a list | options: array of strings |
{"name": "Encrypt", "cmd": "gpg -c --cipher-algo {{algo}} file", "prompts": [
{"key": "algo", "message": "Algorithm", "type": "choice", "options": ["AES256", "TWOFISH", "CAMELLIA256"]}
]}Prompt the user directly from any script - plugin pick scripts, custom scripts, or anywhere. Uses the same prompt spec format.
# Text prompt
host=$(pal prompt '{"message": "Hostname"}')
# Choice prompt
algo=$(pal prompt '{"message": "Algorithm", "type": "choice", "options": ["AES256", "TWOFISH"]}')
# Multiple prompts - returns JSON object
result=$(pal prompt '[{"key": "host", "message": "Host"}, {"key": "port", "message": "Port"}]')
# → {"host": "myserver", "port": "8080"}
# From stdin
cat prompts.json | pal promptWhen called inside a pal flow (e.g., from a plugin pick script), it uses the current frontend (_PAL_FRONTEND). When called standalone, it uses the config default.
Example plugin pick script using pal prompt:
pick() {
item=$(cat)
host=$(pal prompt '{"message": "Hostname"}')
[ -z "$host" ] && exit 0
ssh "$host"
}For palettes with expensive list operations (like combine with many sub-palettes), enable caching to pre-compute the frontend display:
[palette.combine]
base = "builtin/palettes/combine"
include = ["apps", "bookmarks", "cmds"]
cache = trueOn first run, items are listed, formatted, and cached at ~/.cache/pal/. Subsequent runs read directly from cache and regenerate in the background for next time. Currently supported for the rofi frontend.
Frontends are subprocesses pal pipes items through, which only works for launchers you can drive from a shell. A resident app like Raycast has to drive pal instead, so the same flow is available headlessly:
pal meta # every palette and what it can do, as JSON
pal meta ssh # one palette
pal list ssh # items as JSON lines
pal list calc --query "2+2" # input palettes
pal list repos --stream # emit items as they are produced
echo "$item" | pal pick ssh # run the pick
echo "$item" | pal pick systemd --action restart # ...a specific actionThe raycast/ directory is a Raycast extension built on exactly
these commands - see its README for the full setup.
An item may offer several actions instead of a single pick. --action selects
one by id; without it the primary action wins, else the first.
{"name": "nginx.service", "actions": [
{"id": "restart", "title": "Restart", "shortcut": "cmd+r",
"action": "cmd", "value": "systemctl restart nginx", "reload": true},
{"id": "logs", "title": "Show Logs", "action": "pick"},
{"id": "stop", "title": "Stop", "style": "destructive",
"confirm": "Stop nginx?", "action": "cmd", "value": "systemctl stop nginx"}
]}| Field | Description |
|---|---|
id, title, icon |
Identity and display |
action |
Action to run; pick hands back to the plugin's own pick |
value / key |
The value to act on, inline or read from an item field |
shortcut |
cmd+r style hint for frontends that bind keys |
style |
destructive |
confirm |
Ask before running |
reload |
Refresh the list afterwards |
primary |
Default action for this item |
Palettes can declare actions in config as defaults for items that don't
bring their own.
A pick may print a JSON envelope saying what should happen next. Terminal frontends have no vocabulary for this, so pal renders it for them (a notification, a copy); rich frontends consume it directly. Anything that isn't an envelope is passed through unchanged.
{"toast": {"style": "success", "title": "Restarted", "message": "nginx"}}
{"hud": "Copied 483920"}
{"clipboard": "483920"}
{"open": "https://example.com"}
{"show": {"markdown": "...", "metadata": [{"label": "Memory", "text": "12.4M"}]}}
{"reload": true}
{"close": true}This is how the builtin copy and open actions report themselves, which is
why they no longer call notify-send directly.
Every field below is optional and ignored by frontends that can't show it.
| Field | Description |
|---|---|
subtitle |
Secondary text (fzf dims it after the name) |
keywords |
Extra strings to match on |
section |
Group heading; combine sets it to the source palette |
color |
Icon tint - a name or #rrggbb |
accessories |
Trailing metadata: {text|tag|date, color, icon, tooltip} |
detail |
{markdown, metadata: [{label, text, link, tags, icon}]} |
preview |
Shell command producing detail lazily, with PAL_* set |
quicklook |
{path, name} for frontends with a file preview |
icon_rc |
Explicit icon name for rich frontends, alongside icon_xdg/icon_utf |
Palettes may also declare view = "grid", and ttl to tell a driver how long
it may reuse cached items.
Plugins are directories with a plugin.toml and an executable.
name = "my-palette"
desc = "Description of my palette"
version = "0.1"
command = ["run.sh"]#!/usr/bin/env bash
list() {
echo '{"id":"1","name":"Item 1","icon":"folder"}'
echo '{"id":"2","name":"Item 2","icon":"file"}'
}
pick() {
item=$(cat)
id=$(echo "$item" | jq -r '.id')
echo "Selected: $id"
}
case "$1" in
list) list ;;
pick) pick ;;
esacPlugins receive their config via environment variable:
# In your plugin
cfg=$(echo "$_PAL_PLUGIN_CONFIG" | jq -r '.my_setting')Load plugins directly from GitHub repositories:
[palette.ip]
base = "github:zcag/pal/plugins/palettes/ip"
# With specific branch or tag
[palette.ip]
base = "github:zcag/pal/plugins/palettes/ip@v1.0"
# Data files also support github: URLs
[palette.colors]
base = "github:zcag/pal/plugins/palettes/colors"
data = "github:zcag/pal/plugins/palettes/colors/data.json"Plugins are cloned on first use to ~/.local/share/pal/plugins/ using git sparse checkout. Requires git to be installed.
The plugins/ directory contains ready-to-use plugins. Use them directly via GitHub:
[palette.audio]
base = "github:zcag/pal/plugins/palettes/audio"| Plugin | Description |
|---|---|
audio |
Switch audio output devices (PipeWire/PulseAudio) |
clipboard |
Clipboard history (cliphist/clipman) |
wifi |
Connect to WiFi networks (nmcli) |
windows |
Focus windows (Hyprland/Sway/X11) |
systemd |
Manage systemd services |
ble |
Connect Bluetooth devices |
hue |
Control Philips Hue scenes |
repos |
Browse GitHub repositories (gh cli) |
chars |
Unicode character picker |
icons |
Freedesktop icon picker |
nerd |
Nerd Font icon picker |
emoji |
Emoji picker |
colors |
Color picker (hex/rgb/hsl) |
calc |
Calculator (qalc/bc) |
ip |
Network info (public/local IP, gateway, DNS) |
docker |
Docker container management |
op |
1Password items |
media |
Media player control (playerctl) |
power |
Power menu (shutdown, reboot, etc.) |
Actions define what happens when an item is picked with auto_pick:
[palette.commands]
auto_list = true
auto_pick = true
data = "commands.json"
default_action = "cmd" # run as shell command
action_key = "cmd" # field containing the command| Action | Description |
|---|---|
cmd |
Execute the value as a shell command |
copy |
Copy value to clipboard (wl-copy/xclip/pbcopy) with notification |
open |
Open value with xdg-open/open |
Actions are resolved locally first (plugins/actions/ in config dir), then fetched from GitHub as a fallback.
When an item is picked, all its JSON keys are injected as PAL_<KEY> environment variables into the action process:
{"name": "Red", "hex": "#ff0000", "rgb": "255,0,0"}# Available in your action script:
echo $PAL_NAME # Red
echo $PAL_HEX # #ff0000
echo $PAL_RGB # 255,0,0This works for both auto_pick actions and plugin-based palettes.
Create custom actions as plugins in plugins/actions/:
# plugins/actions/notify/run.sh
run() {
notify-send "$PAL_NAME" "$PAL_DESCRIPTION"
}| Variable | Description |
|---|---|
_PAL_CONFIG |
Path to current config file |
_PAL_CONFIG_DIR |
Directory of current config file |
_PAL_PALETTE |
Current palette name |
_PAL_FRONTEND |
Current frontend name |
_PAL_PLUGIN_CONFIG |
JSON config for current plugin |
PAL_<KEY> |
Item key-value pairs injected on pick (e.g. PAL_NAME, PAL_HEX) |
[palette.launcher]
base = "builtin/palettes/combine"
include = ["apps", "bookmarks", "quickcmds"]# Terminal
alias p="pal run fzf"
# Desktop (bind to hotkey)
pal run rofi combineCreate pal.toml in your project:
[palette.project]
auto_list = true
data = "scripts.json"
default_action = "cmd"
action_key = "cmd"- capability system between palettes (or items of palettes) and fe's
- hotlink support.
pal://run?fe=rofi?palette=commands?item="confetti" -
pal integrate xdgfor registering hotlink -
pal doctorfor config validation - REST API frontend (for driving palettes on another host)
This is a rewrite of my personal bash spaghetti that I implemented over the years, covering many palettes and frontends for various stuff. Inspired by Raycast - an awesome macOS Spotlight alternative that's also quite customizable. Many of the custom palettes here are ported from my custom Raycast plugins after I left macOS.
This is also an experiment for myself on Rust and AI-assisted coding. I have minimal Rust knowledge, and this is my first time properly using an AI agent for development. Claude Code was heavily used in this project - it straight up implemented a ton of the palettes based on my descriptions and reference bash scripts from the original pal.
MIT