Skip to content

Commit c5a9da9

Browse files
committed
feat(plugins): add exec, keymap, and CLI command APIs
- cliamp.exec.run(): allowlisted subprocess spawn for plugins, gated by permissions = {"exec"} - p:bind([desc,] fn): plugin-registered TUI keybindings, gated by permissions = {"keymap"}; entries with a description appear in the Ctrl+K overlay. Core-reserved keys rejected at bind time. - p:command(name, fn): shell-invokable plugin commands, dispatched over IPC via `cliamp plugins call <plugin> <command> [args...]` and listed via `cliamp plugins commands`. - cliamp.fs: added mkdir/listdir; ~/Music/cliamp/ writable.
1 parent 9635d2b commit c5a9da9

21 files changed

Lines changed: 1867 additions & 113 deletions

commands.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"strconv"
99
"strings"
10+
"time"
1011

1112
cli "github.com/urfave/cli/v3"
1213

@@ -229,6 +230,48 @@ func pluginsCommand() *cli.Command {
229230
return pluginmgr.Remove(c.Args().First())
230231
},
231232
},
233+
{
234+
Name: "call",
235+
Usage: "invoke a plugin command in the running cliamp",
236+
ArgsUsage: "<plugin> <command> [args...]",
237+
Action: func(ctx context.Context, c *cli.Command) error {
238+
args := c.Args().Slice()
239+
if len(args) < 2 {
240+
return fmt.Errorf("usage: cliamp plugins call <plugin> <command> [args...]")
241+
}
242+
resp, err := ipcSendLong(ipc.Request{
243+
Cmd: "plugin.call",
244+
Name: args[0],
245+
Sub: args[1],
246+
Args: args[2:],
247+
}, 6*time.Minute)
248+
if err != nil {
249+
return err
250+
}
251+
if resp.Output != "" {
252+
fmt.Println(resp.Output)
253+
}
254+
return nil
255+
},
256+
},
257+
{
258+
Name: "commands",
259+
Usage: "list plugin commands registered in the running cliamp",
260+
Action: func(ctx context.Context, c *cli.Command) error {
261+
resp, err := ipcSend(ipc.Request{Cmd: "plugin.commands"})
262+
if err != nil {
263+
return err
264+
}
265+
if len(resp.Items) == 0 {
266+
fmt.Println("No plugin commands registered.")
267+
return nil
268+
}
269+
for _, item := range resp.Items {
270+
fmt.Println(item)
271+
}
272+
return nil
273+
},
274+
},
232275
},
233276
}
234277
}

docs/plugins.md

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,47 @@ Plugins subscribe to events with `p:on(event, callback)`. Callbacks run asynchro
167167

168168
The `status` field in `playback.state` is one of: `"playing"`, `"paused"`, `"stopped"`.
169169

170+
## Plugin object methods
171+
172+
The object returned by `plugin.register(...)` exposes additional methods beyond `:on()` / `:config()`:
173+
174+
### `p:bind(key, [description,] callback)` — keyboard binding (requires `permissions = {"keymap"}`)
175+
176+
```lua
177+
local p = plugin.register({
178+
name = "my-plugin",
179+
type = "hook",
180+
permissions = {"keymap"},
181+
})
182+
183+
-- Listed in the Ctrl+K overlay under "— plugins —":
184+
p:bind("x", "Extract chapters", function(key) ... end)
185+
186+
-- Not listed (hidden binding):
187+
p:bind("ctrl+e", function(key) ... end)
188+
```
189+
190+
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.
191+
192+
Key strings are in Bubbletea's `msg.String()` form: lowercase letters, `ctrl+` / `shift+` / `alt+` prefixes (e.g. `"x"`, `"ctrl+e"`, `"shift+f1"`). Case-insensitive.
193+
194+
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`.
195+
196+
Use `p:unbind(key)` to release a binding.
197+
198+
### `p:command(name, callback)` — shell-invokable command
199+
200+
```lua
201+
p:command("run", function(args)
202+
-- args is an array of strings passed after the command name
203+
return "done: " .. args[1]
204+
end)
205+
```
206+
207+
The callback can return a string, which is printed by the CLI client. Commands are invoked from the shell via `cliamp plugins call <plugin-name> <command> [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).
208+
209+
List all registered commands with `cliamp plugins commands`. Commands can run for up to 5 minutes before timing out.
210+
170211
## Lua API
171212

172213
All APIs are under the `cliamp` global table.
@@ -232,9 +273,11 @@ cliamp.fs.append(path, content) -- append to file
232273
cliamp.fs.read(path) --> string (max 1 MB)
233274
cliamp.fs.remove(path) -- delete file
234275
cliamp.fs.exists(path) --> boolean
276+
cliamp.fs.mkdir(path) -- create directory (recursive)
277+
cliamp.fs.listdir(path) --> {names}, err
235278
```
236279

237-
Writes are restricted to `/tmp/`, `~/.config/cliamp/`, and `~/.local/share/cliamp/`. Reads are allowed from anywhere.
280+
Writes are restricted to `/tmp/`, `~/.config/cliamp/`, `~/.local/share/cliamp/`, and `~/Music/cliamp/`. Reads are allowed from anywhere.
238281

239282
### cliamp.json
240283

@@ -297,6 +340,46 @@ cliamp.notify("Song Title", "Artist Name") -- notification with title and body
297340

298341
Sends a desktop notification via `notify-send`. Works with mako, dunst, and other notification daemons.
299342

343+
### cliamp.exec (requires permissions)
344+
345+
Plugins that declare `permissions = {"exec"}` can spawn subprocesses from a configurable binary allowlist. Default allowlist: `yt-dlp`, `ffmpeg`. Extend it in `config.toml`:
346+
347+
```toml
348+
[plugins]
349+
allowed_binaries = "ffprobe, curl" # merged with defaults
350+
```
351+
352+
```lua
353+
local p = plugin.register({
354+
name = "my-downloader",
355+
type = "hook",
356+
permissions = {"exec"},
357+
})
358+
359+
local handle, err = cliamp.exec.run("yt-dlp", {"--dump-json", url}, {
360+
on_stdout = function(line) ... end, -- optional, called per line
361+
on_stderr = function(line) ... end, -- optional
362+
on_exit = function(code) ... end, -- optional, fires exactly once
363+
cwd = "/tmp/work", -- optional; must be in write allowlist
364+
timeout = 300, -- optional seconds, hard cap 1800
365+
})
366+
367+
handle:cancel() -- terminate the process
368+
handle:alive() -- --> boolean
369+
```
370+
371+
**Safety rails:**
372+
373+
- Binary must be in the allowlist. Argv is argv — no shell, no expansion.
374+
- `args` must be a flat array of strings. Nested tables / non-strings are rejected.
375+
- Subprocess env is minimal (`PATH`, `HOME`, `LANG`) — secrets in the parent env are not passed through.
376+
- Output is capped at 4 MiB per process (stdout + stderr combined); further lines are dropped silently.
377+
- Concurrency capped at 4 running processes per plugin.
378+
- All processes owned by a plugin are killed on plugin unload and on cliamp exit.
379+
- Negative `on_exit` codes signal cancellation/timeout (`-1`) or spawn failure (`-2`).
380+
381+
Without `permissions = {"exec"}`, `cliamp.exec.run` returns `nil, "exec permission required"`.
382+
300383
### cliamp.message
301384

302385
```lua
@@ -425,7 +508,7 @@ For security, plugins run with restricted access. The sandbox removes dangerous
425508

426509
| Removed | Replacement |
427510
|---------|-------------|
428-
| `os.execute`, `os.remove`, `os.rename`, `os.exit`, `os.setlocale`, `os.tmpname` | Use `cliamp.http` or `cliamp.fs` |
511+
| `os.execute`, `os.remove`, `os.rename`, `os.exit`, `os.setlocale`, `os.tmpname` | Use `cliamp.fs`, `cliamp.http`, or permission-gated `cliamp.exec` |
429512
| `io` module (all of it) | Use `cliamp.fs` |
430513
| `dofile`, `loadfile` | Not available |
431514

@@ -437,11 +520,12 @@ For security, plugins run with restricted access. The sandbox removes dangerous
437520

438521
**Reads:** Allowed from any path (max 1 MB per read).
439522

440-
**Writes/removes** are restricted to these directories only:
523+
**Writes/removes/mkdir** are restricted to these directories only:
441524

442525
- `/tmp/` (and the system temp directory)
443526
- `~/.config/cliamp/`
444527
- `~/.local/share/cliamp/`
528+
- `~/Music/cliamp/`
445529

446530
Attempts to write outside these directories will raise a Lua error. Directory traversal (`..`) is blocked.
447531

ipc/client.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ func DefaultSocketPath() string {
2626
// Send connects to the IPC socket, sends a request, and returns the response.
2727
// The connection is closed after a single request/response exchange.
2828
func Send(sockPath string, req Request) (Response, error) {
29+
return SendWithDeadline(sockPath, req, 5*time.Second)
30+
}
31+
32+
// SendWithDeadline is like Send but lets the caller override the exchange
33+
// deadline. Plugin commands can legitimately run for minutes (downloads), so
34+
// the generic 5s cap is too short for them.
35+
func SendWithDeadline(sockPath string, req Request, deadline time.Duration) (Response, error) {
2936
conn, err := net.DialTimeout("unix", sockPath, 3*time.Second)
3037
if err != nil {
3138
if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) {
@@ -35,8 +42,7 @@ func Send(sockPath string, req Request) (Response, error) {
3542
}
3643
defer conn.Close()
3744

38-
// Set a deadline for the entire exchange.
39-
conn.SetDeadline(time.Now().Add(5 * time.Second))
45+
conn.SetDeadline(time.Now().Add(deadline))
4046

4147
// Encode and send the request as a single JSON line.
4248
data, err := json.Marshal(req)

ipc/protocol.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ var _ Dispatcher = DispatcherFunc(nil)
99

1010
// Request is the JSON command sent by the client.
1111
type Request struct {
12-
Cmd string `json:"cmd"`
13-
Value float64 `json:"value,omitempty"`
14-
Playlist string `json:"playlist,omitempty"`
15-
Path string `json:"path,omitempty"`
16-
Name string `json:"name,omitempty"`
17-
Band int `json:"band,omitempty"`
12+
Cmd string `json:"cmd"`
13+
Value float64 `json:"value,omitempty"`
14+
Playlist string `json:"playlist,omitempty"`
15+
Path string `json:"path,omitempty"`
16+
Name string `json:"name,omitempty"`
17+
Band int `json:"band,omitempty"`
18+
Sub string `json:"sub,omitempty"`
19+
Args []string `json:"args,omitempty"`
1820
}
1921

2022
// Response is the JSON response sent by the server.
@@ -36,6 +38,16 @@ type Response struct {
3638
Speed float64 `json:"speed,omitempty"`
3739
EQPreset string `json:"eq_preset,omitempty"`
3840
Device string `json:"device,omitempty"`
41+
Output string `json:"output,omitempty"`
42+
Items []string `json:"items,omitempty"`
43+
}
44+
45+
// PluginDispatcher is the hook the IPC server calls to forward plugin.call and
46+
// plugin.commands requests to the Lua plugin manager. Optional — if nil, those
47+
// subcommands return an error.
48+
type PluginDispatcher interface {
49+
EmitCommand(plugin, command string, args []string) (string, error)
50+
CommandList() []string
3951
}
4052

4153
// TrackInfo is the track metadata in a status response.

ipc/server.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,17 @@ type Server struct {
2727
listener net.Listener
2828
sockPath string
2929
disp Dispatcher
30+
plugins PluginDispatcher
3031
done chan struct{}
3132
wg sync.WaitGroup
3233
}
3334

35+
// SetPluginDispatcher wires in the Lua plugin manager after the server starts.
36+
// Plugin dispatch is optional — without it, plugin subcommands return an error.
37+
func (s *Server) SetPluginDispatcher(p PluginDispatcher) {
38+
s.plugins = p
39+
}
40+
3441
// NewServer creates and starts the IPC server. It cleans up stale sockets
3542
// before binding. The socket is created with 0600 permissions (owner only).
3643
func NewServer(sockPath string, disp Dispatcher) (*Server, error) {
@@ -258,6 +265,25 @@ func (s *Server) dispatch(req Request) Response {
258265
case "status":
259266
return s.handleStatus()
260267

268+
case "plugin.call":
269+
if s.plugins == nil {
270+
return Response{OK: false, Error: "plugins not enabled"}
271+
}
272+
if req.Name == "" || req.Sub == "" {
273+
return Response{OK: false, Error: "plugin.call requires plugin name and subcommand"}
274+
}
275+
out, err := s.plugins.EmitCommand(req.Name, req.Sub, req.Args)
276+
if err != nil {
277+
return Response{OK: false, Error: err.Error()}
278+
}
279+
return Response{OK: true, Output: out}
280+
281+
case "plugin.commands":
282+
if s.plugins == nil {
283+
return Response{OK: false, Error: "plugins not enabled"}
284+
}
285+
return Response{OK: true, Items: s.plugins.CommandList()}
286+
261287
default:
262288
return Response{OK: false, Error: "unknown command: " + req.Cmd}
263289
}

luaplugin/api_control.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func registerControlAPI(L *lua.LState, cliamp *lua.LTable, ctrl *ControlProvider
1414

1515
warned := false
1616
guard := func(name string) bool {
17-
if !p.perms["control"] {
17+
if !p.perms[PermControl] {
1818
if !warned {
1919
logger.log(p.Name, "warn", "%s requires permissions = {\"control\"} — further warnings suppressed", name)
2020
warned = true

0 commit comments

Comments
 (0)