cliamp uses Lua 5.1 plugins. Plugins can handle playback events, such as scrobbling, notifications, and status-bar output. They can also add visualizers. Each plugin runs in an isolated VM. A plugin crash does not affect other plugins or the player.
Store plugins in ~/.config/cliamp/plugins/. Create the directory:
mkdir -p ~/.config/cliamp/plugins
cliamp runs a plugin only after you approve its exact contents. Existing and manually copied plugins start as untrusted. Approve one with cliamp plugins trust <name>.
cliamp plugins # show help
cliamp plugins list # list installed plugins
cliamp plugins install <source> # install a plugin
cliamp plugins trust <name> # approve installed plugin contents
cliamp plugins remove <name> # remove a plugin and its approvalThe install and trust commands show the source, SHA-256, declared permissions, and implicit file-system and network access before the prompt. In a non-interactive environment, use --yes only after you review the same content independently. cliamp stores approvals in plugins/.trust.json. Editing a plugin changes its hash and disables it until you approve it again. Removing a plugin also removes its approval.
cliamp approves only the content and the permissions that the prompt showed. If the file changes while the prompt waits, cliamp plugins trust approves nothing and fails. If the plugin.register() call of a plugin declares another permission at runtime, the plugin does not load. An approval from an older cliamp has no recorded permissions. For such an approval, cliamp approves the permissions that the check below finds in the file.
If plugins/.trust.json does not parse, cliamp treats every plugin as untrusted and logs the error to plugins.log. cliamp plugins list then shows each plugin as untrusted and prints the error. cliamp plugins install stops before it downloads the plugin, cliamp plugins trust stops before it asks, and cliamp plugins remove stops before it deletes the plugin. To recover, delete the file and approve each plugin again. Then run the install or remove command again.
The install and trust commands check the plugin.register() call the same way the player does. They reject a plugin whose plugin.register() call the player rejects, such as a call with an unknown permission name, or without type = "hook" or type = "visualizer". The check runs the plugin file with a stand-in cliamp table that does nothing, and print does nothing too. Thus the plugin cannot do any work or write to the terminal before you approve it. cliamp plugins list drops control characters, such as escape sequences, from the name, version, and description of each plugin.
| Format | Example |
|---|---|
| GitHub | user/repo |
| GitHub with tag | user/[email protected] |
| GitLab | gitlab:user/repo |
| GitLab with tag | gitlab:user/[email protected] |
| Codeberg | codeberg:user/repo |
| Codeberg with tag | codeberg:user/[email protected] |
| Direct URL | https://example.com/plugin.lua |
Name plugin repositories cliamp-plugin-<name>. Put the <name>.lua entry point in the repository root. cliamp removes the cliamp-plugin- prefix during installation. For example, cliamp-plugin-soap-bubbles, which contains soap-bubbles.lua, installs as soap-bubbles.
cliamp plugins install bjarneo/cliamp-plugin-lastfm
cliamp plugins install bjarneo/[email protected]
cliamp plugins install gitlab:user/my-visualizer
cliamp plugins install codeberg:user/my-plugin
cliamp plugins install https://example.com/my-plugin.lua
cliamp plugins remove lastfm-- ~/.config/cliamp/plugins/now-playing.lua
local p = plugin.register({
name = "now-playing",
type = "hook",
description = "Write now-playing to /tmp for status bars",
})
p:on("track.change", function(track)
cliamp.fs.write("/tmp/cliamp-now-playing", track.artist .. " - " .. track.title)
end)
p:on("playback.state", function(ev)
if ev.status == "paused" then
cliamp.fs.write("/tmp/cliamp-now-playing", "paused")
end
end)
p:on("app.quit", function()
cliamp.fs.remove("/tmp/cliamp-now-playing")
end)-- ~/.config/cliamp/plugins/notify.lua
local p = plugin.register({
name = "notify",
type = "hook",
})
p:on("track.change", function(track)
local title = track.artist .. " - " .. track.title
os.execute('notify-send "cliamp" "' .. title .. '"')
end)os.execute is not available in the sandbox. Use cliamp.http for public HTTP endpoints. cliamp blocks private, loopback, link-local, multicast, and unspecified addresses. For local automation, write to an allowed file for a watcher to read. You can also declare the permission-gated exec capability.
-- ~/.config/cliamp/plugins/webhook.lua
local p = plugin.register({
name = "webhook",
type = "hook",
})
local url = p:config("url")
p:on("track.change", function(track)
if not url then return end
cliamp.http.post(url, {
json = { title = track.title, artist = track.artist, album = track.album }
})
end)# config.toml
[plugins.webhook]
url = "https://example.com/hook"~/.config/cliamp/plugins/myplugin.lua
~/.config/cliamp/plugins/myplugin/
init.lua
helpers.lua
The directory name is the plugin name. cliamp loads only init.lua automatically. If myplugin.lua and myplugin/init.lua both exist, cliamp and cliamp plugins use myplugin.lua.
Each plugin must call plugin.register(). cliamp skips files that do not call it. The type field is required. A plugin.register() call without type = "hook" or type = "visualizer" is a load error. cliamp shows the error at startup and does not load the plugin. Call plugin.register() once. A second call raises an error, so it cannot change the permissions after you approve the plugin. The top-level code of the plugin file must finish in 5 seconds. A plugin that runs longer at load is a load error. The cliamp.player controls and the cliamp.queue edits do nothing in the top-level code, because the player is not ready yet. Call them from an event handler, such as app.start.
local p = plugin.register({
name = "myplugin", -- unique; default: the installed name
type = "hook", -- required: "hook" or "visualizer"
version = "1.0.0", -- optional
description = "What it does", -- optional
})Each loaded plugin must have its own name. cliamp loads plugins in the order of their installed names. If a plugin registers a name that an earlier plugin already uses, cliamp reports a load error and does not load it. If you omit name, cliamp uses the installed name: the file name without .lua, or the directory name. Commands, key binding descriptions, and visualizer modes use name. Config, trust, cliamp.store, event topics, and plugins.log use the installed name.
The returned p object provides these methods:
| Method | Description |
|---|---|
p:on(event, callback) |
Subscribe to a playback event |
p:config(key) |
Read a config value from [plugins.myplugin] in config.toml |
p:publish(topic, payload, options) |
Publish a namespaced event to local IPC subscribers |
Plugins can publish JSON-compatible values to external programs that connect to
Cliamp's owner-only IPC socket. cliamp puts each topic below the installed plugin
name. For example, myplugin.lua that publishes "playback" uses the topic
plugin.myplugin.playback. The name provides one topic segment. cliamp replaces
each character other than a letter, digit, _, or - with _. Therefore,
my.plugin.lua uses the plugin.my_plugin.* prefix. Publishing "playback"
from that plugin uses plugin.my_plugin.playback. It cannot collide with topics
from a plugin named my.
This conversion loses information, so namespaces are unique for a session. If
my.plugin.lua and my_plugin.lua are both installed, the first loaded plugin
(plugins load in name order) owns the plugin.my_plugin.* prefix. In the other
plugin, p:publish() returns nil, err and names the owner. Rename one plugin
to publish from both. Subscribers must specify complete topics, not prefixes.
p:publish("playback", {
status = cliamp.player.state(),
title = cliamp.track.title(),
}, { retain = true })With retain = true, Cliamp keeps the latest value in memory and sends it to
new subscribers immediately. Cliamp discards retained values when it exits. It
does not save event data to disk. Publishing does not block. cliamp disconnects
a subscriber that cannot keep up instead of blocking the player.
Open cliamp.sock and send one NDJSON request to subscribe:
{"version":2,"id":"events","method":"subscribe","topics":["plugin.myplugin.playback"]}After {"version":2,"id":"events","ok":true}, the connection becomes a
server-to-client event stream:
{"event":"plugin.myplugin.playback","seq":42,"time":1786685741,"retained":true,"data":{"status":"playing","title":"Track"}}Subscriptions use exact topic matches and accept at most 32 topics. They are
for streaming only. Use another IPC connection for normal commands. Sending
more bytes on a subscription closes it. Payloads can be at most 64 KiB. cliamp
converts them with the nesting and cycle rules of
cliamp.json. Topic segments can contain letters, digits, .,
_, and -. p:publish() needs no permission. IPC is local to the same user,
and the status command already exposes playback metadata.
Use p:on(event, callback) to subscribe to events. Each plugin gets its events and key presses one at a time, in the order cliamp sent them. Different plugins run in parallel. Each callback times out after 5 seconds.
Up to 256 events and key presses can wait for one plugin. If a plugin falls further behind, cliamp drops its new events and key presses until it catches up, and logs one warning to plugins.log. At shutdown, cliamp first stops each command that still runs. Then it runs the events that wait for up to 2 seconds and drops the rest. Then it runs the app.quit handlers one at a time, with the same 5 second limit. After that, cliamp stops each timer callback and exec callback that still runs.
| Event | Callback argument | When |
|---|---|---|
track.change |
{title, artist, album, genre, year, path, duration, stream} |
New track starts successfully |
track.scrobble |
Same + {played_secs} |
You left a track that played at least 50% of its known duration: it ended, or you skipped, stopped, removed it from the queue, rewound with previous, or started another track. Each start of a track fires this at most once |
playback.state |
{status, title, artist, album, path, duration, stream, position} |
Once for each playback state change (play, pause, stop, seek, volume, track transition, new stream title), and once per second while a track plays. A message that changes nothing does not fire it |
player.seek |
{position, duration} (seconds) |
A seek completes |
player.volume |
{db} |
Volume changes |
player.eq |
{bands, preset} |
An EQ band or preset changes |
player.mode |
{shuffle, repeat} |
Shuffle toggled or repeat mode cycled |
queue.change |
{count, index, queued} |
Playlist or play-next queue changes |
queue.end |
Same as track.change, for the finished track |
Advancing past the last track stopped playback, whether the track ended or the user skipped. A manual stop does not fire this |
playback.stop |
{} |
The user stopped playback with a key, IPC, or media controls. The queue running out does not fire this |
app.start |
{} |
After all plugins loaded |
app.quit |
{} |
Before shutdown |
In playback.state, status is "playing", "paused", or "stopped". In player.mode, repeat is "Off", "All", or "One", matching cliamp.player.repeat_mode(). In player.eq, bands is an array of 10 dB values.
In track.change, track.scrobble, playback.state and queue.end, duration is the length in whole seconds. A track with no length in its metadata, such as a local file, gets the decoded length. duration is 0 when the length is unknown, as for a live stream.
track.change fires after playback starts successfully for all sources, including YouTube and SoundCloud. A stream that is still buffering, fails to start, or is superseded before it starts does not emit this event. Gapless transitions also emit track.change.
queue.end reports the last track that emitted track.change. It fires when the track drains, at a gapless boundary with nothing queued, on next from the last track, and on next after the playlist was emptied while the track played, or replaced by a list with no playable track. A stream still buffering when the queue runs out is not reported, and a failed start emits nothing. After a failed start, queue.end does not fire until another track starts, even if the previous track is still playing.
cliamp sends player.* and queue.change events by comparing state after each UI update. They cover every source, including a keypress, IPC, MPRIS, or another plugin.
An event hook sees the state that includes the change that the event reports. For example, cliamp.track.title() in a track.change hook returns the title of the track that started.
The object from plugin.register(...) provides these methods in addition to :on() and :config():
local p = plugin.register({
name = "my-plugin",
type = "hook",
permissions = {"keymap"},
})
-- Listed in the Ctrl+K overlay under "— plugins —":
p:bind("ctrl+n", "Extract chapters", function(key) ... end)
-- Not listed (hidden binding):
p:bind("ctrl+t", function(key) ... end)Returns true on success. Returns false, reason when cliamp's core UI owns the key or the plugin lacks the keymap permission. Pass a description as the middle argument to show the binding in the Ctrl+K keymap overlay. Omit it for an internal-only binding.
Use Bubbletea's msg.String() form for key strings: lowercase letters and the ctrl+, shift+, or alt+ prefixes. For example: "n", "ctrl+y", and "shift+f1". Key strings are case-insensitive.
Plugin keys work only in the main view. Overlays such as the file browser, theme picker, and keymap capture their own input. The core reserves the keys of its own commands, in the main view and in each overlay. It also reserves the text-editor keys Ctrl+A, Ctrl+E, Ctrl+W, Ctrl+U, Home, End, Delete, and Backspace. The cursor aliases Ctrl+N and Ctrl+P stay free. A bind of a reserved key logs a warning to plugins.log and returns false, reason.
Use p:unbind(key) to release a binding.
p:command("run", function(args)
-- args is an array of strings passed after the command name
return "done: " .. args[1]
end)The callback can return a string. The CLI client prints it. Invoke commands from the shell with cliamp plugins call <plugin-name> <command> [args...]. cliamp sends the command to the running player over IPC. Commands need no separate permission because the user starts them.
List registered commands with cliamp plugins commands. A command can run for up to 5 minutes before it times out. cliamp also stops a command when its IPC job is canceled and when cliamp quits.
All APIs are in the global cliamp table.
Each function reports a failure in one fixed way:
- A function that returns a value returns
niland an error message. Examples arecliamp.fs.read,cliamp.http.get,cliamp.json.decode,cliamp.store.set,cliamp.exec.run,cliamp.queue.add(track), andp:publish. p:bindreturnsfalseand a reason.- The controls in
cliamp.player, andcliamp.queue.add(path),jump,remove, andmove, return nothing. - A bad argument raises a Lua error.
cliamp.fs.write,append,remove, andmkdiralso raise a Lua error for a path outside the allowed write directories. Usepcallto catch it.cliamp.exec.runreturnsnil, "cwd not in write allowlist"for such acwd.
If a plugin calls a function that needs a permission it did not declare, the function does nothing and returns its usual failure result. cliamp logs one warning to plugins.log for each missing permission.
local ok, err = pcall(cliamp.fs.write, "/etc/motd", "text")
if not ok then cliamp.log.warn(err) endcliamp.player.state() --> "playing" | "paused" | "stopped"
cliamp.player.position() --> number (seconds)
cliamp.player.duration() --> number (seconds)
cliamp.player.volume() --> number (dB, volume_min to +6)
cliamp.player.speed() --> number (ratio, 1.0 = normal)
cliamp.player.mono() --> boolean
cliamp.player.repeat_mode() --> "Off" | "All" | "One"
cliamp.player.shuffle() --> boolean
cliamp.player.eq_bands() --> table of 10 dB valuescliamp.track.title() --> string
cliamp.track.artist() --> string
cliamp.track.album() --> string
cliamp.track.genre() --> string
cliamp.track.year() --> number
cliamp.track.track_number() --> number
cliamp.track.path() --> string
cliamp.track.is_stream() --> boolean
cliamp.track.is_live() --> boolean (no track boundary: radio, or a stream that is live now)
cliamp.track.duration_secs() --> numbercliamp.track reports the track that plays, as playback.state does. When you load a playlist during playback, the old track plays on, and cliamp.track reports it until the next track starts. For a radio stream, title and artist come from the stream title when the station sends one. When nothing plays, cliamp.track reports the current track of the queue.
cliamp updates the values of cliamp.player, cliamp.track, and cliamp.queue after it handles each key, message, or tick. cliamp.player.position() and cliamp.player.duration() read the audio engine directly.
You can read the playlist without permission. To change it, declare permissions = {"control"}. All indices are 0-based, as in cliamp.queue.current().
-- read (no permission)
cliamp.queue.list() --> array of {title, artist, album, genre, year, path, duration, stream, index, queued}
cliamp.queue.count() --> number of tracks
cliamp.queue.current() --> 0-based index of the current track
cliamp.queue.has_next() --> true when a playable track follows in play order (play-next queue, repeat, shuffle)
-- mutate (requires "control")
cliamp.queue.add(path) -- resolve a file/dir/URL and append
cliamp.queue.add(track) --> true | nil, err -- append a track table as given
cliamp.queue.jump(index) -- make index current and play it
cliamp.queue.remove(index) -- remove the track at index
cliamp.queue.move(from, to) -- swap the tracks at from and toadd, remove, and move follow the rules of the Shift+Up, Shift+Down,
and x keys and of IPC:
- While shuffle is on,
movechanges nothing. - When the queue mirrors a saved local playlist,
movesaves the new order to that playlist.removeremoves the track from it too. When that save fails, the edit changes nothing. Favorites is not a playlist file, so an edit of a loaded Favorites list changes only the queue. add,move, andremoverecord no undo. After one of them,Ctrl+Zdoes not undo the last TUI edit, because that undo would drop the new change.- While the queue mirrors a saved local playlist,
removedoes not remove a track that a directory source of that playlist supplies. A removal of the playing track stops playback. addappends. The queue then mirrors no saved playlist.- When an edit changes the next track, cliamp re-arms the gapless preload.
add accepts every input that the CLI accepts: a local file or directory, an
HTTP stream, an M3U/PLS URL, or a YouTube/yt-dlp URL. cliamp resolves it off the
UI thread. A slow URL does not block playback.
add also takes a track table with the same keys as the track tables in
events: {path, title, artist, album, genre, year, duration, stream}. Only
path is required, and other keys are ignored, so a track from an event or from
queue.list() can be passed back as it is. cliamp appends the track exactly as
described, without resolving the path. Use it for tracks a path alone can't
describe, such as a provider track from another service:
local ok, err = cliamp.queue.add({
path = "spotify:track:69kOkLUCkxIZYexIgSG8rq",
title = "Get Lucky", artist = "Daft Punk", duration = 369,
})
if not ok then cliamp.log.warn(err) endIt returns true, or nil and an error message when the table is invalid (for
example, a missing path or a title that is not a string) or when the plugin
lacks the control permission. An HTTP URL is always marked as a stream.
-- GET
local body, status = cliamp.http.get("https://api.example.com/data", {
headers = { Authorization = "Bearer token" }
})
-- POST with JSON
local body, status = cliamp.http.post("https://api.example.com/scrobble", {
json = { artist = "Radiohead", track = "Everything In Its Right Place" }
})
-- POST with form body
local body, status = cliamp.http.post(url, {
headers = { ["Content-Type"] = "application/x-www-form-urlencoded" },
body = "key=value&foo=bar"
})The timeout is 5 seconds. A request also ends when the time limit of the running callback ends, such as the 50 ms limit of a visualizer render. The response body limit is 1 MB.
cliamp.fs.write(path, content) -- overwrite file
cliamp.fs.append(path, content) -- append to file
cliamp.fs.read(path) --> string (max 1 MB)
cliamp.fs.remove(path) -- delete file
cliamp.fs.exists(path) --> boolean
cliamp.fs.mkdir(path) -- create directory (recursive)
cliamp.fs.listdir(path) --> {names}, errYou can write only to the system temp directory (/tmp/ on Unix), ~/.config/cliamp/, ~/.local/share/cliamp/, and ~/Music/cliamp/. In ~/.config/cliamp/, you cannot write to the plugins/ directory, config.toml, radios.toml, cliamp.sock, cliamp.sock.pid, or plugins.log. You can read from any path. cliamp.fs.read reads only regular files. For a FIFO, a device, or a directory, it returns nil and an error, because a read of such a file can block without a limit. On Windows, the config directory resolves to %APPDATA%\cliamp unless HOME names another directory. If that directory has no config.toml and HOME/.config/cliamp has one, cliamp keeps the existing directory.
local tbl = cliamp.json.decode('{"key": "value"}')
local str = cliamp.json.encode({ key = "value" })cliamp encodes tables to a depth of 64 levels. A deeper table or a cyclic
reference becomes null; it does not fail. p:publish() and cliamp.store
use the same conversion.
This is a persistent key/value store for each plugin. Strings, numbers,
booleans, and tables survive restarts. No permission is required. cliamp keys
each store by the installed name, and cliamp.store reaches only the store of
the calling plugin. The store is not secret. Another plugin can read or change
the store file with cliamp.fs. Do not keep secrets in it.
cliamp.store.set(key, value) -- value: string|number|boolean|table
cliamp.store.get(key) --> value or nil
cliamp.store.delete(key)
cliamp.store.keys() --> sorted array of keys
cliamp.store.clear()set returns nil and an error for a value that JSON cannot hold, such as
0/0 or 1/0, also inside a table. The store keeps the old value of the key.
cliamp stores this data in ~/.local/share/cliamp/plugins/<name>/store.json
with owner-only mode (0600). Use it for play counts, offline scrobble queues,
resume positions, and saved settings. Do not use it for large data.
local counts = cliamp.store.get("counts") or {}
counts[cliamp.track.path()] = (counts[cliamp.track.path()] or 0) + 1
cliamp.store.set("counts", counts)cliamp.crypto.md5("hello") --> hex string
cliamp.crypto.sha256("hello") --> hex string
cliamp.crypto.hmac_sha256("secret", "msg") --> hex stringcliamp.log.info("loaded successfully")
cliamp.log.warn("missing config key")
cliamp.log.error("request failed: " .. err)
cliamp.log.debug("response: " .. body)cliamp writes logs to ~/.config/cliamp/plugins.log. Each line has a timestamp and the installed name of the plugin as the prefix, for example [now-playing]. print(...) also writes to this file, at the info level, with its arguments joined by tabs. It never writes to the terminal.
cliamp also logs the Lua errors of event hooks, key bindings, commands, timers, exec callbacks, and visualizer callbacks to this file. A callback that fails again with the same error logs it once. cliamp logs it again after the callback succeeds or fails with a different error. A visualizer render runs on each frame, so cliamp logs only its first error and its first timeout until cliamp restarts. The log entry for a timeout names the time limit. cliamp does not write plugin errors to the terminal.
Plugins that declare permissions = {"control"} can control the player:
local p = plugin.register({
name = "my-controller",
type = "hook",
permissions = {"control"},
})
cliamp.player.next() -- skip to next track
cliamp.player.prev() -- go to previous track
cliamp.player.play_pause() -- toggle play/pause
cliamp.player.stop() -- stop playback
cliamp.player.set_volume(-5) -- set volume in dB (volume_min to +6)
cliamp.player.set_speed(1.25) -- set playback speed (0.25 to 2.0)
cliamp.player.seek(30) -- seek forward 30 seconds (a negative value seeks back)
cliamp.player.toggle_mono() -- toggle mono output
cliamp.player.set_eq_preset("Rock") -- switch to built-in preset (sets bands + UI label)
cliamp.player.set_eq_preset("Metal", {6,4,1,-1,-2,2,4,6,6,5}) -- custom preset with bands
cliamp.player.set_eq_band(1, 6) -- set EQ band 1 to +6 dB (bands 1-10, -12 to +12)If a plugin does not declare permissions = {"control"}, these functions log a warning and do nothing.
Each control returns at once. cliamp applies the controls in the order that the plugin calls them, on the same loop that handles the keys. Thus a read such as cliamp.player.volume() right after set_volume can still return the old value. cliamp saves a speed change to config.toml after one second, as the speed keys do. A volume change updates the volume of the media controls at once.
cliamp.notify("Song Title") -- notification with title only
cliamp.notify("Song Title", "Artist Name") -- notification with title and bodyThis sends a desktop notification through notify-send. It works with mako, dunst, and other notification daemons. cliamp stops notify-send after 2 seconds, or earlier when the time limit of the running callback ends.
Plugins that declare permissions = {"exec"} can start subprocesses from a configurable binary allowlist. The default allowlist is yt-dlp, ffmpeg. Add binaries in config.toml:
[plugins]
allowed_binaries = "ffprobe, curl" # merged with defaultslocal p = plugin.register({
name = "my-downloader",
type = "hook",
permissions = {"exec"},
})
local handle, err = cliamp.exec.run("yt-dlp", {"--dump-json", url}, {
on_stdout = function(line) ... end, -- optional, called per line
on_stderr = function(line) ... end, -- optional
on_exit = function(code) ... end, -- optional, fires exactly once
cwd = "/tmp/work", -- optional; must be in write allowlist
timeout = 300, -- optional seconds, hard cap 1800
})
handle:cancel() -- terminate the process
handle:alive() -- --> booleanSafety rules:
- The binary must be in the allowlist. Argv is argv. No shell or expansion is used.
- The read-only paths of
cliamp.fsapply only tocliamp.fsand tocwd. With the exec permission,yt-dlporffmpegcan write to any path that you can write. Examples are the--execoption ofyt-dlpand an output path offfmpeg. argsmust be a flat array of strings. cliamp rejects nested tables and non-strings.- The subprocess environment contains only
PATH,HOME, andLANG. cliamp does not pass the other variables of its environment. This does not hide secrets from the plugin, which can read each variable withos.getenv(). - Output is limited to 4 MiB per process, for stdout and stderr together. cliamp silently drops later lines.
- A line is limited to 1 MiB. After a longer line, cliamp silently drops the rest of that stream. The process continues to run.
- Each plugin can run up to 4 processes at one time.
- cliamp kills every plugin-owned process when the plugin unloads and when cliamp exits.
- On Linux and macOS, a cancel or a timeout kills the process and the child processes that it started, such as the
ffmpegofyt-dlp. cliamp closes the output pipes 0.5 seconds later, so a child that left the process group cannot keepon_exitfrom running. - Negative
on_exitcodes indicate cancellation or timeout (-1), or a start failure (-2). - Each call of
on_stdout,on_stderr, oron_exittimes out after 5 seconds.
Without permissions = {"exec"}, cliamp.exec.run returns nil, "exec permission required".
cliamp.message("Scrobble Sent") -- show for default duration
cliamp.message("Syncing Library", 5) -- show for 5 secondsThis shows a temporary message in the status bar at the bottom of the UI. The duration is optional and uses seconds. Omit it to use the default TTL. cliamp limits durations above 60 seconds. The call returns at once, and cliamp shows the messages and applies the controls of a plugin in the order of the calls.
cliamp.sleep(2.5) -- block for 2.5 seconds (max 10)This blocks the plugin's Lua VM. Other hooks for the same plugin wait until the sleep ends. Use cliamp.timer.after() for a non-blocking delay. The sleep ends early when the time limit of the running callback or of the plugin load ends.
-- Run once after 5 seconds
local id = cliamp.timer.after(5.0, function()
cliamp.log.info("timer fired")
end)
-- Run every 30 seconds
local id = cliamp.timer.every(30.0, function()
-- periodic task
end)
-- Cancel
cliamp.timer.cancel(id)Each timer callback times out after 5 seconds.
Put plugin-specific configuration in config.toml under [plugins.<name>]:
[plugins.lastfm]
api_key = "abc123"
api_secret = "secret"
session_key = "sk-xxx"
[plugins.webhook]
url = "https://example.com/hook"Read it in Lua:
local api_key = p:config("api_key") --> "abc123" or nilDisable one plugin:
[plugins.webhook]
enabled = falseTo disable several plugins:
[plugins]
disabled = webhook, discord-rpcA list in square brackets also works, such as disabled = ["webhook", "discord-rpc"]. allowed_binaries accepts the same forms. enabled accepts every bool form in Value syntax, such as False.
Plugins with type = "visualizer" add visualizer modes to the v key cycle with the built-in modes. A name selects the built-in mode when a visualizer plugin has the same name, for example Bars, in cliamp vis <name> and in the visualizer config key. Use the v key or the picker to select that plugin, or give it another name.
-- ~/.config/cliamp/plugins/simple-bars.lua
local p = plugin.register({
name = "simple-bars",
type = "visualizer",
})
-- Called every frame (~20 FPS during playback).
-- bands: table of 10 numbers (0.0-1.0), indices 1-10, eased between
-- analyses as in the built-in spectrum modes
-- frame: monotonic counter
-- rows: available terminal rows (changes in fullscreen mode)
-- cols: available terminal columns
-- Must return a multi-line string.
function p:render(bands, frame, rows, cols)
local lines = {}
local chars = { " ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" }
for row = 5, 1, -1 do
local line = ""
for i = 1, 10 do
local level = bands[i]
local threshold = (row - 1) / 5
if level > threshold then
line = line .. "██████ "
else
line = line .. " "
end
end
table.insert(lines, line)
end
return table.concat(lines, "\n")
end| Callback | Signature | Required |
|---|---|---|
p:render(bands, frame, rows, cols) |
Returns string | Yes |
p:init(rows, cols) |
Setup when selected | No |
p:destroy() |
Cleanup when deselected | No |
cliamp calls init before the first frame after you select the visualizer, with the size of that frame. It calls destroy when you select another visualizer after init ran. It calls neither when you leave the visualizer before it draws a frame, and it does not call destroy when cliamp quits. Use the app.quit event for cleanup at exit.
render has a 50 ms limit for each frame. If it runs longer or fails, cliamp shows the previous frame. cliamp also shows the previous frame while another callback of the same plugin runs, so a slow hook does not delay the UI. cliamp runs init and destroy in order with the events of the plugin, and render shows the previous frame until init has run.
For security, plugins have restricted access. The sandbox removes unsafe standard-library functions and limits file-system access.
| Removed | Replacement |
|---|---|
os.execute, os.remove, os.rename, os.exit, os.setlocale, os.tmpname |
Use cliamp.fs, cliamp.http, or permission-gated cliamp.exec |
io module (all of it) |
Use cliamp.fs |
dofile, loadfile, load, loadstring, require, module, package, debug |
Not available |
You can use os.time(), os.date(), os.clock(), and os.getenv(). os.getenv() reads each variable of the cliamp environment, including tokens and passwords.
Reads: You can read any regular file, up to 1 MB for each read.
Writes/removes/mkdir work only in these directories:
/tmp/(and the system temp directory)~/.config/cliamp/~/.local/share/cliamp/~/Music/cliamp/
These paths in ~/.config/cliamp/ stay read-only for cliamp.fs and for the cwd of cliamp.exec:
plugins/, which contains the plugin files andplugins/.trust.jsonconfig.tomlradios.tomlcliamp.sockcliamp.sock.pidplugins.log
Thus a plugin cannot use cliamp.fs to approve plugins, change the exec allowlist, stop the next start of cliamp, or hide its log. A plugin with the exec permission can still write to any path that you can write through yt-dlp or ffmpeg. Approve a plugin that declares the exec permission only when you trust it.
Writing outside these directories, or to a read-only path, raises a Lua error. cliamp resolves symlinks and blocks directory traversal (..) before it checks the path. The cwd of cliamp.exec follows the same rules.
- Each plugin has its own Lua VM. A plugin cannot reach the Lua variables of another plugin.
- The sandbox does not keep secrets from a plugin.
cliamp.fsreads each file that you can read. Examples areconfig.toml, with the[plugins.<name>]keys of every plugin, and thecliamp.storefile of another plugin.os.getenv()reads the environment. Install only plugins that you trust with these values. - A plugin crash does not affect other plugins or the player.
- Use
cliamp.httpfor public network access. Raw socket access is not available. cliamp blocks private, loopback, link-local, multicast, and unspecified destinations after DNS resolution and through redirects. os.executeis not available. Permission-gatedcliamp.execcan start only configured allowed binaries.
Check ~/.config/cliamp/plugins.log for plugin output and errors:
2025-03-29 14:30:01 [now-playing] info: Now playing: Everything In Its Right Place
2025-03-29 14:30:01 [webhook] error: track.change handler error: connection refused
Use cliamp.log.debug() during development.