# Lua Plugins cliamp has a Lua 5.1 plugin system. Plugins can hook into playback events (scrobbling, notifications, status bar output) and add custom visualizers. Each plugin runs in an isolated VM. A crash in one plugin cannot affect others or the player. Plugins live in `~/.config/cliamp/plugins/`. Create the directory: ``` mkdir -p ~/.config/cliamp/plugins ``` Plugins run only after their exact contents have been approved. Existing and manually copied plugins start untrusted; approve one with `cliamp plugins trust `. ## Plugin manager ```sh cliamp plugins # show help cliamp plugins list # list installed plugins cliamp plugins install # install a plugin cliamp plugins trust # approve installed plugin contents cliamp plugins remove # remove a plugin ``` Install and trust display the source, SHA-256, declared permissions, and implicit filesystem/network access before prompting. Use `--yes` only after independently reviewing the same content in non-interactive environments. Approvals are stored in `plugins/.trust.json`; editing a plugin changes its hash and disables it until it is approved again. Unknown permission names are rejected. ### Install sources | Format | Example | |--------|---------| | GitHub | `user/repo` | | GitHub with tag | `user/repo@v1.0` | | GitLab | `gitlab:user/repo` | | GitLab with tag | `gitlab:user/repo@v1.0` | | Codeberg | `codeberg:user/repo` | | Codeberg with tag | `codeberg:user/repo@v1.0` | | Direct URL | `https://example.com/plugin.lua` | ### Naming convention Plugin repositories **must** be named `cliamp-plugin-` with the entry point `.lua` at the repo root. The `cliamp-plugin-` prefix is stripped on install, so `cliamp-plugin-soap-bubbles` (containing `soap-bubbles.lua`) installs as `soap-bubbles`. ```sh cliamp plugins install bjarneo/cliamp-plugin-lastfm cliamp plugins install bjarneo/cliamp-plugin-lastfm@v1.0 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 ``` ## Quick start ### Now-playing file (for Waybar, Polybar, etc.) ```lua -- ~/.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) ``` ### Desktop notification on track change ```lua -- ~/.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) ``` Note: `os.execute` is removed by the sandbox. Public HTTP endpoints are available through `cliamp.http`; private, loopback, link-local, multicast, and unspecified addresses are blocked. For local automation, write to an allowlisted file that a watcher picks up or declare the permission-gated `exec` capability. ### Webhook ```lua -- ~/.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) ``` ```toml # config.toml [plugins.webhook] url = "https://example.com/hook" ``` ## Plugin structure ### Single file ``` ~/.config/cliamp/plugins/myplugin.lua ``` ### Directory with init.lua ``` ~/.config/cliamp/plugins/myplugin/ init.lua helpers.lua ``` The directory name becomes the plugin name. Only `init.lua` is loaded automatically. ## Registration Every plugin must call `plugin.register()` to be recognized. Files that don't call it are silently skipped. ```lua local p = plugin.register({ name = "myplugin", -- required type = "hook", -- "hook" or "visualizer" version = "1.0.0", -- optional description = "What it does", -- optional }) ``` The returned object `p` provides two 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 | ## Events Plugins subscribe to events with `p:on(event, callback)`. Callbacks run asynchronously in goroutines and have a 5-second timeout. ### Available events | Event | Callback argument | When | |-------|-------------------|------| | `track.change` | `{title, artist, album, genre, year, path, duration, stream}` | New track starts | | `track.scrobble` | Same + `{played_secs}` | Track played >= 50% or >= 4 min | | `playback.state` | `{status, title, artist, album, path, duration, stream, position}` | Any playback state change (play, pause, stop, seek, volume, track transition) | | `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 | | `app.start` | `{}` | After all plugins loaded | | `app.quit` | `{}` | Before shutdown | The `status` field in `playback.state` is one of: `"playing"`, `"paused"`, `"stopped"`. The `repeat` field in `player.mode` is one of: `"Off"`, `"All"`, `"One"` (matching `cliamp.player.repeat_mode()`). In `player.eq`, `bands` is an array of 10 dB values. The `player.*` and `queue.change` events are fired by diffing state after each UI update, so they cover every change regardless of source (keypress, IPC, MPRIS, or another plugin). ## Plugin object methods The object returned by `plugin.register(...)` exposes additional methods beyond `:on()` / `:config()`: ### `p:bind(key, [description,] callback)` — keyboard binding (requires `permissions = {"keymap"}`) ```lua local p = plugin.register({ name = "my-plugin", type = "hook", permissions = {"keymap"}, }) -- Listed in the Ctrl+K overlay under "— plugins —": p:bind("x", "Extract chapters", function(key) ... end) -- Not listed (hidden binding): p:bind("ctrl+e", function(key) ... end) ``` Returns `true` on success, or `false, reason` if the key is already owned by cliamp's core UI or the plugin lacks the `keymap` permission. Pass a description string as the middle argument to surface the binding in the `Ctrl+K` keymap overlay; omit it for an internal-only binding. Key strings are in Bubbletea's `msg.String()` form: lowercase letters, `ctrl+` / `shift+` / `alt+` prefixes (e.g. `"x"`, `"ctrl+e"`, `"shift+f1"`). Case-insensitive. Plugin keys only fire in the main view — overlays like the file browser, theme picker, and keymap itself capture their own input. Core reserves all keys documented in `docs/keybindings.md`; trying to bind one of those logs a warning and returns `false`. Use `p:unbind(key)` to release a binding. ### `p:command(name, callback)` — shell-invokable command ```lua 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, which is printed by the CLI client. Commands are invoked from the shell via `cliamp plugins call [args...]` and dispatched to the running cliamp over IPC. Since dispatch runs in the running player, commands don't need a separate permission (they're user-initiated). List all registered commands with `cliamp plugins commands`. Commands can run for up to 5 minutes before timing out. ## Lua API All APIs are under the `cliamp` global table. ### cliamp.player (read-only) ```lua cliamp.player.state() --> "playing" | "paused" | "stopped" cliamp.player.position() --> number (seconds) cliamp.player.duration() --> number (seconds) cliamp.player.volume() --> number (dB, -30 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 values ``` ### cliamp.track (read-only) ```lua cliamp.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.duration_secs() --> number ``` ### cliamp.queue Read the playlist freely; mutating it requires `permissions = {"control"}`. All indices are 0-based, matching `cliamp.queue.current()`. ```lua -- read (no permission) cliamp.queue.list() --> array of {title, artist, album, path, index, queued} cliamp.queue.count() --> number of tracks cliamp.queue.current() --> 0-based index of the current track -- mutate (requires "control") cliamp.queue.add(path) -- resolve a file/dir/URL and append cliamp.queue.jump(index) -- make index current and play it cliamp.queue.remove(index) -- remove the track at index cliamp.queue.move(from, to) -- reorder a track ``` `add` accepts anything the CLI accepts: a local file or directory, an HTTP stream, an M3U/PLS URL, or a YouTube/yt-dlp URL. Resolution happens off the UI thread, so a slow URL never blocks playback. ### cliamp.http ```lua -- 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" }) ``` Restrictions: 5-second timeout, 1 MB response body cap. ### cliamp.fs ```lua 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}, err ``` Writes are restricted to the system temp directory (`/tmp/` on Unix), `~/.config/cliamp/`, `~/.local/share/cliamp/`, and `~/Music/cliamp/`. Reads are allowed from anywhere. On Windows, if `HOME` is unset, the config directory portion resolves to `%APPDATA%\cliamp`. ### cliamp.json ```lua local tbl = cliamp.json.decode('{"key": "value"}') local str = cliamp.json.encode({ key = "value" }) ``` ### cliamp.store A persistent per-plugin key/value store. Values (strings, numbers, booleans, and tables) survive restarts. No permission required: each plugin sees only its own namespace, so one plugin can never read another's keys. ```lua 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() ``` Backed by `~/.local/share/cliamp/plugins//store.json`, written owner-only (0600). Use it for play counts, offline scrobble queues, resume positions, or remembered settings, not for large data. ```lua 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 ```lua cliamp.crypto.md5("hello") --> hex string cliamp.crypto.sha256("hello") --> hex string cliamp.crypto.hmac_sha256("secret", "msg") --> hex string ``` ### cliamp.log ```lua cliamp.log.info("loaded successfully") cliamp.log.warn("missing config key") cliamp.log.error("request failed: " .. err) cliamp.log.debug("response: " .. body) ``` Logs are written to `~/.config/cliamp/plugins.log` with timestamps and `[plugin-name]` prefix. ### cliamp.player control (requires permissions) Plugins that declare `permissions = {"control"}` can send commands to the player: ```lua 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 (-30 to +6) cliamp.player.set_speed(1.25) -- set playback speed (0.25 to 2.0) cliamp.player.seek(30) -- seek to 30 seconds 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) ``` Without `permissions = {"control"}`, these functions log a warning and do nothing. ### cliamp.notify ```lua cliamp.notify("Song Title") -- notification with title only cliamp.notify("Song Title", "Artist Name") -- notification with title and body ``` Sends a desktop notification via `notify-send`. Works with mako, dunst, and other notification daemons. ### cliamp.exec (requires permissions) Plugins that declare `permissions = {"exec"}` can spawn subprocesses from a configurable binary allowlist. Default allowlist: `yt-dlp`, `ffmpeg`. Extend it in `config.toml`: ```toml [plugins] allowed_binaries = "ffprobe, curl" # merged with defaults ``` ```lua local 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() -- --> boolean ``` **Safety rails:** - Binary must be in the allowlist. Argv is argv — no shell, no expansion. - `args` must be a flat array of strings. Nested tables / non-strings are rejected. - Subprocess env is minimal (`PATH`, `HOME`, `LANG`) — secrets in the parent env are not passed through. - Output is capped at 4 MiB per process (stdout + stderr combined); further lines are dropped silently. - Concurrency capped at 4 running processes per plugin. - All processes owned by a plugin are killed on plugin unload and on cliamp exit. - Negative `on_exit` codes signal cancellation/timeout (`-1`) or spawn failure (`-2`). Without `permissions = {"exec"}`, `cliamp.exec.run` returns `nil, "exec permission required"`. ### cliamp.message ```lua cliamp.message("Scrobble Sent") -- show for default duration cliamp.message("Syncing Library", 5) -- show for 5 seconds ``` Displays a transient message in the status bar at the bottom of the UI. The duration argument is optional (seconds); omit it to use the default TTL. Durations above 60 seconds are clamped. ### cliamp.sleep ```lua cliamp.sleep(2.5) -- block for 2.5 seconds (max 10) ``` Blocks the plugin's Lua VM. Other hooks for the same plugin will queue until the sleep finishes. Prefer `cliamp.timer.after()` for non-blocking delays. ### cliamp.timer ```lua -- 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) ``` ## Configuration Plugin-specific config goes in `config.toml` under `[plugins.]`: ```toml [plugins.lastfm] api_key = "abc123" api_secret = "secret" session_key = "sk-xxx" [plugins.webhook] url = "https://example.com/hook" ``` Access in Lua: ```lua local api_key = p:config("api_key") --> "abc123" or nil ``` ### Disabling plugins Disable a specific plugin: ```toml [plugins.webhook] enabled = false ``` Or disable multiple at once: ```toml [plugins] disabled = webhook, discord-rpc ``` ## Visualizer plugins Plugins with `type = "visualizer"` add custom visualizer modes that appear in the `v` key cycle alongside built-in modes. ```lua -- ~/.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 -- 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 ``` ### Visualizer callbacks | 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 | Render has a 10 ms budget per frame. If it exceeds this, the previous frame is reused to prevent UI jank. ## Sandbox For security, plugins run with restricted access. The sandbox removes dangerous standard library functions and restricts file system access. ### Removed functions | 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 | ### Kept functions `os.time()`, `os.date()`, `os.clock()`, `os.getenv()` are available. ### File system restrictions **Reads:** Allowed from any path (max 1 MB per read). **Writes/removes/mkdir** are restricted to these directories only: - `/tmp/` (and the system temp directory) - `~/.config/cliamp/` - `~/.local/share/cliamp/` - `~/Music/cliamp/` Attempts to write outside these directories will raise a Lua error. Directory traversal (`..`) is blocked. ### Isolation - Each plugin runs in its own Lua VM. Plugins cannot access each other's state or variables. - A crash in one plugin does not affect other plugins or the player. - Public network access is available via `cliamp.http` (no raw socket access). Private, loopback, link-local, multicast, and unspecified destinations are blocked after DNS resolution and across redirects. - `os.execute` is removed. Permission-gated `cliamp.exec` can spawn only configured allowlisted binaries. ## Debugging 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()` liberally during development.