diff --git a/docs/plugins.md b/docs/plugins.md index a6b7d7c..c10e3c9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -33,14 +33,14 @@ Plugin = { ### Hook reference -| Hook | When called | Sync/async | Return value | -| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- | -| `on_config()` | At startup and on config save | always sync | ignored | -| `on_start()` | Once at startup, after `on_config` | configurable | `false` to self-disable the plugin, otherwise ignored | -| `on_quit()` | When the app exits | always sync | ignored | -| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (sync only) | -| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (sync only) | -| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) -- sync only | +| Hook | When called | Sync/async | Return value | +| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- | +| `on_config()` | At startup and on config save | always sync | ignored | +| `on_start()` | Once at startup, after `on_config` | configurable | `false` to self-disable the plugin, otherwise ignored | +| `on_quit()` | When the app exits | always sync | ignored | +| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (sync only) | +| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (sync only) | +| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) (sync only) | ## Request and response objects @@ -130,28 +130,32 @@ local cfg = get_config() ### Finding deduplication -A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts. If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again. +A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts. +If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again. ## Configuration -Plugin configuration is stored in a `plugins.yaml` file alongside the project database. Each plugin has an `enable` toggle and an optional `config` block (arbitrary YAML). +Plugin configuration is stored in a `plugins.yaml` file alongside the project database. +Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML). ```yaml plugins: - my_plugin: - enable: true - config: | - some_key: some_value - list: - - item1 - - item2 - other_plugin: - enable: false + my_plugin: + enable: true + config: + some_key: some_value + list: + - item1 + - item2 + other_plugin: + enable: false ``` -The config block is edited from the **Plugins** page in the TUI. Inside a plugin, call `get_config()` to retrieve the config as a Lua table. +The config block is edited from the **Plugins** page in the TUI. +Inside a plugin, call `get_config()` to retrieve the config as a Lua table. -`on_config()` is called once at startup (before `on_start`) and again every time the user saves the config in the TUI. It is the right place to read `get_config()` and populate local variables. +`on_config()` is called once at startup (before `on_start`) and again every time the user saves the config in the TUI. +It is the right place to read `get_config()` and populate local variables. ```lua local items = {} @@ -174,49 +178,13 @@ end `on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration. -### Return values for sync hooks - -**`on_start`:** - -| Return value | Effect | -| ------------ | -------------------------------------------------------------------------------------------- | -| `false` | The plugin is disabled immediately and the state is persisted (equivalent to toggling it off). | -| anything else | Ignored. | - -This is useful for prerequisite checks (binary not found, config invalid, etc.) so the plugin does not silently run in a broken state: - -```lua -function on_start() - local h = io.popen("command -v mytool 2>/dev/null") - local result = h and h:read("*a") or "" - if h then h:close() end - if result:match("^%s*$") then - notif("MyPlugin", "mytool not found, plugin disabled", "error") - return false - end -end -``` - -**`on_request` and `on_response`:** - -| Return value | Effect | -| ------------ | --------------------------------------------------------------------------------- | -| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. | -| `"forward"` | The flow is forwarded immediately without going through the intercept panel. | -| `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. | - -**`on_history_entry` (sync only):** - -| Return value | Effect | -| ----------------- | --------------------------------- | -| `"skip"` | The entry is not saved to the DB. | -| `"keep"` or `nil` | The entry is saved normally. | - -Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it. +Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. +Async `on_history_entry` runs **after** the insert and cannot affect it. ## Priority -Plugins with a higher `priority` value run before plugins with a lower value (default `0`). This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins. +Plugins with a higher `priority` value run before plugins with a lower value (default `0`). +This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins. ```lua Plugin = { diff --git a/internal/plugins/manager.go b/internal/plugins/manager.go index 9ed21ef..1c92ab0 100644 --- a/internal/plugins/manager.go +++ b/internal/plugins/manager.go @@ -68,11 +68,11 @@ func (m *Manager) LoadFromDir(dir string) error { continue } if m.pluginsFile != nil { - if enabled, configText, found := m.pluginsFile.get(p.Name); found { + if enabled, configText, found := m.pluginsFile.get(p.ID); found { p.Enabled = enabled p.ConfigText = configText } else { - m.pluginsFile.register(p.Name, p.Enabled) + m.pluginsFile.register(p.ID, p.Enabled) } } m.mu.Lock() @@ -87,6 +87,7 @@ func (m *Manager) LoadFromDir(dir string) error { func (m *Manager) loadPlugin(path string) (*Plugin, error) { p := &Plugin{ + ID: strings.TrimSuffix(filepath.Base(path), ".lua"), FilePath: path, Enabled: true, hooks: make(map[string]HookConfig), @@ -107,7 +108,7 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) { p.Name = string(s) } if p.Name == "" { - p.Name = strings.TrimSuffix(filepath.Base(path), ".lua") + p.Name = p.ID } if s, ok := pluginTable.RawGetString("description").(lua.LString); ok { @@ -155,11 +156,11 @@ func (m *Manager) GetPlugins() []*Plugin { return out } -func (m *Manager) TogglePlugin(name string) { +func (m *Manager) TogglePlugin(id string) { m.mu.RLock() var found *Plugin for _, p := range m.plugins { - if p.Name == name { + if p.ID == id { found = p break } @@ -173,8 +174,8 @@ func (m *Manager) TogglePlugin(name string) { enabled := found.Enabled found.mu.Unlock() if m.pluginsFile != nil { - if err := m.pluginsFile.setEnabled(name, enabled); err != nil { - log.Printf("plugin %s: save state: %v", name, err) + if err := m.pluginsFile.setEnabled(id, enabled); err != nil { + log.Printf("plugin %s: save state: %v", id, err) } } if !enabled { @@ -188,8 +189,8 @@ func (m *Manager) TogglePlugin(name string) { if ret == lua.LFalse { p.Enabled = false if m.pluginsFile != nil { - if err := m.pluginsFile.setEnabled(p.Name, false); err != nil { - log.Printf("plugin %s: save state: %v", p.Name, err) + if err := m.pluginsFile.setEnabled(p.ID, false); err != nil { + log.Printf("plugin %s: save state: %v", p.ID, err) } } } @@ -217,11 +218,11 @@ func (m *Manager) TogglePlugin(name string) { } } -func (m *Manager) SaveConfig(name, configText string) { +func (m *Manager) SaveConfig(id, configText string) { m.mu.RLock() var found *Plugin for _, p := range m.plugins { - if p.Name == name { + if p.ID == id { found = p break } @@ -234,8 +235,8 @@ func (m *Manager) SaveConfig(name, configText string) { found.ConfigText = configText found.mu.Unlock() if m.pluginsFile != nil { - if err := m.pluginsFile.setConfig(name, configText); err != nil { - log.Printf("plugin %s: save config: %v", name, err) + if err := m.pluginsFile.setConfig(id, configText); err != nil { + log.Printf("plugin %s: save config: %v", id, err) } } if _, ok := found.hooks["on_config"]; !ok { @@ -243,7 +244,7 @@ func (m *Manager) SaveConfig(name, configText string) { } found.mu.Lock() if _, err := callHook(found, "on_config"); err != nil { - log.Printf("plugin %s on_config: %v", name, err) + log.Printf("plugin %s on_config: %v", id, err) } found.mu.Unlock() } @@ -273,8 +274,8 @@ func (m *Manager) RunOnStart() { if ret == lua.LFalse { p.Enabled = false if m.pluginsFile != nil { - if err := m.pluginsFile.setEnabled(p.Name, false); err != nil { - log.Printf("plugin %s: save state: %v", p.Name, err) + if err := m.pluginsFile.setEnabled(p.ID, false); err != nil { + log.Printf("plugin %s: save state: %v", p.ID, err) } } } diff --git a/internal/plugins/pluginsfile.go b/internal/plugins/pluginsfile.go index 05d05b3..23dccaa 100644 --- a/internal/plugins/pluginsfile.go +++ b/internal/plugins/pluginsfile.go @@ -8,8 +8,8 @@ import ( ) type pluginFileEntry struct { - Enable bool `yaml:"enable"` - Config string `yaml:"config,omitempty"` + Enable bool `yaml:"enable"` + Config interface{} `yaml:"config,omitempty"` } type pluginsFileData struct { @@ -51,28 +51,46 @@ func (pf *PluginsFile) save() error { return os.WriteFile(pf.path, raw, 0o600) } -func (pf *PluginsFile) get(name string) (enabled bool, config string, found bool) { - e, ok := pf.data.Plugins[name] - return e.Enable, e.Config, ok +func (pf *PluginsFile) get(id string) (enabled bool, config string, found bool) { + e, ok := pf.data.Plugins[id] + if !ok { + return false, "", false + } + if e.Config == nil { + return e.Enable, "", true + } + raw, err := yaml.Marshal(e.Config) + if err != nil { + return e.Enable, "", true + } + return e.Enable, string(raw), true } -func (pf *PluginsFile) register(name string, defaultEnabled bool) { - if _, ok := pf.data.Plugins[name]; !ok { - pf.data.Plugins[name] = pluginFileEntry{Enable: defaultEnabled} +func (pf *PluginsFile) register(id string, defaultEnabled bool) { + if _, ok := pf.data.Plugins[id]; !ok { + pf.data.Plugins[id] = pluginFileEntry{Enable: defaultEnabled} _ = pf.save() } } -func (pf *PluginsFile) setEnabled(name string, enabled bool) error { - e := pf.data.Plugins[name] +func (pf *PluginsFile) setEnabled(id string, enabled bool) error { + e := pf.data.Plugins[id] e.Enable = enabled - pf.data.Plugins[name] = e + pf.data.Plugins[id] = e return pf.save() } -func (pf *PluginsFile) setConfig(name string, configText string) error { - e := pf.data.Plugins[name] - e.Config = configText - pf.data.Plugins[name] = e +func (pf *PluginsFile) setConfig(id string, configText string) error { + e := pf.data.Plugins[id] + if configText == "" { + e.Config = nil + } else { + var parsed interface{} + if err := yaml.Unmarshal([]byte(configText), &parsed); err != nil { + return err + } + e.Config = parsed + } + pf.data.Plugins[id] = e return pf.save() } diff --git a/internal/plugins/types.go b/internal/plugins/types.go index 9be136b..f7b1f09 100644 --- a/internal/plugins/types.go +++ b/internal/plugins/types.go @@ -11,6 +11,7 @@ type HookConfig struct { } type Plugin struct { + ID string Name string Description string FilePath string @@ -37,6 +38,7 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) { } type Info struct { + ID string Name string Description string FilePath string @@ -57,6 +59,7 @@ func (p *Plugin) Info() Info { hooks[k] = v } return Info{ + ID: p.ID, Name: p.Name, Description: p.Description, FilePath: p.FilePath, diff --git a/internal/ui/plugins/update.go b/internal/ui/plugins/update.go index d6dc2b3..0f79b08 100644 --- a/internal/ui/plugins/update.go +++ b/internal/ui/plugins/update.go @@ -59,11 +59,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textarea.Blur() if info, ok := m.selected(); ok && m.manager != nil { val := m.textarea.Value() - m.manager.SaveConfig(info.Name, val) + m.manager.SaveConfig(info.ID, val) // Update cached info. m.filtered[m.cursor].ConfigText = val for i := range m.items { - if m.items[i].Name == info.Name { + if m.items[i].ID == info.ID { m.items[i].ConfigText = val break } @@ -107,10 +107,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, pk.Toggle): if info, ok := m.selected(); ok && m.manager != nil { - m.manager.TogglePlugin(info.Name) + m.manager.TogglePlugin(info.ID) m.filtered[m.cursor].Enabled = !info.Enabled for i := range m.items { - if m.items[i].Name == info.Name { + if m.items[i].ID == info.ID { m.items[i].Enabled = !info.Enabled break }