Skip to main content

lean_ctx/core/plugins/
mod.rs

1pub mod executor;
2pub mod manifest;
3pub mod registry;
4pub mod sandbox;
5pub mod tools;
6
7use executor::{HookPoint, HookResult, execute_hooks_for_point};
8use registry::PluginRegistry;
9use std::sync::Mutex;
10use std::sync::OnceLock;
11
12static GLOBAL_REGISTRY: OnceLock<Mutex<PluginRegistry>> = OnceLock::new();
13
14pub struct PluginManager;
15
16impl PluginManager {
17    pub fn init() {
18        let _ = GLOBAL_REGISTRY.get_or_init(|| {
19            let mut reg = PluginRegistry::from_default_dir();
20            let errors = reg.discover();
21            for err in &errors {
22                tracing::warn!(
23                    "plugin discovery error at {}: {}",
24                    err.path.display(),
25                    err.error
26                );
27            }
28            Mutex::new(reg)
29        });
30    }
31
32    pub fn with_registry<F, R>(f: F) -> Option<R>
33    where
34        F: FnOnce(&PluginRegistry) -> R,
35    {
36        GLOBAL_REGISTRY
37            .get()
38            .and_then(|m| m.lock().ok())
39            .map(|reg| f(&reg))
40    }
41
42    pub fn with_registry_mut<F, R>(f: F) -> Option<R>
43    where
44        F: FnOnce(&mut PluginRegistry) -> R,
45    {
46        GLOBAL_REGISTRY
47            .get()
48            .and_then(|m| m.lock().ok())
49            .map(|mut reg| f(&mut reg))
50    }
51
52    pub fn fire_hook(hook: &HookPoint) -> Vec<HookResult> {
53        Self::with_registry(|reg| {
54            let plugins: Vec<_> = reg.enabled_plugins();
55            execute_hooks_for_point(&plugins, hook)
56        })
57        .unwrap_or_default()
58    }
59
60    pub fn fire_hook_background(hook: HookPoint) {
61        std::thread::spawn(move || {
62            let results = Self::fire_hook(&hook);
63            for r in &results {
64                if !r.success {
65                    tracing::warn!(
66                        "plugin hook failed: {} - {}",
67                        r.plugin_name,
68                        r.error.as_deref().unwrap_or("unknown")
69                    );
70                }
71            }
72        });
73    }
74
75    /// True if any enabled plugin declares `hook_name`. A cheap guard so the hot
76    /// path never spawns a hook thread when nothing would run — the default
77    /// (no plugins installed → registry uninitialized → `false`).
78    pub fn has_listener(hook_name: &str) -> bool {
79        Self::with_registry(|reg| any_enabled_listener(reg, hook_name)).unwrap_or(false)
80    }
81
82    /// Fire a hook in the background, but only when a plugin is actually
83    /// listening for it. Call sites should prefer this over
84    /// `fire_hook_background` so they stay zero-cost without plugins.
85    pub fn notify(hook: HookPoint) {
86        if Self::has_listener(hook.hook_name()) {
87            Self::fire_hook_background(hook);
88        }
89    }
90
91    /// Flattened `[[tools]]` from all enabled plugins (EPIC 12.11). Empty unless
92    /// plugins are installed + enabled, so it is zero-cost by default.
93    pub fn tool_specs() -> Vec<tools::PluginToolSpec> {
94        Self::with_registry(|reg| {
95            reg.enabled_plugins()
96                .iter()
97                .flat_map(|p| {
98                    let policy = p.manifest.trust.policy();
99                    p.manifest.tools.iter().map(move |t| tools::PluginToolSpec {
100                        plugin_name: p.manifest.plugin.name.clone(),
101                        plugin_dir: p.path.clone(),
102                        name: t.name.clone(),
103                        description: t.description.clone(),
104                        command: t.command.clone(),
105                        timeout_ms: t.timeout_ms,
106                        input_schema: t.input_schema.clone(),
107                        policy,
108                    })
109                })
110                .collect()
111        })
112        .unwrap_or_default()
113    }
114}
115
116fn any_enabled_listener(reg: &PluginRegistry, hook_name: &str) -> bool {
117    reg.enabled_plugins()
118        .iter()
119        .any(|p| p.manifest.hooks.contains_key(hook_name))
120}
121
122pub fn init_plugin_template(name: &str, dir: &std::path::Path) -> std::io::Result<()> {
123    let plugin_dir = dir.join(name);
124    std::fs::create_dir_all(&plugin_dir)?;
125
126    let manifest = format!(
127        r#"[plugin]
128name = "{name}"
129version = "0.1.0"
130description = "Description of what this plugin does"
131author = "Your Name"
132
133[hooks.on_session_start]
134command = "{name} start"
135timeout_ms = 5000
136
137[hooks.on_session_end]
138command = "{name} stop"
139
140# [hooks.pre_read]
141# command = "{name} pre-read"
142# timeout_ms = 2000
143
144# [hooks.post_compress]
145# command = "{name} post-compress"
146
147# [hooks.on_knowledge_update]
148# command = "{name} knowledge-updated"
149
150# Native MCP tools (no fork needed). Each [[tools]] entry becomes a tool the
151# agent can call; arguments arrive as JSON on stdin, the result is stdout.
152# [[tools]]
153# name = "{name}_lookup"
154# description = "What this tool does"
155# command = "{name} tool lookup"
156# timeout_ms = 5000
157# input_schema = {{ type = "object", properties = {{ query = {{ type = "string" }} }}, required = ["query"] }}
158
159# Trust & sandbox (least privilege by default). Hooks/tools run with a scrubbed
160# environment and a working-dir jail. Declare only what you need:
161#   network         — you make outbound network calls (surfaced for consent)
162#   fs_write        — you write files outside the plugin dir (surfaced)
163#   env_passthrough — you need the full host env (disables env scrubbing)
164# [trust]
165# permissions = ["network"]
166"#
167    );
168
169    std::fs::write(plugin_dir.join("plugin.toml"), manifest)?;
170
171    let readme = format!(
172        "# {name}\n\n\
173         A lean-ctx plugin.\n\n\
174         ## Installation\n\n\
175         Copy this directory to `~/.config/lean-ctx/plugins/{name}/`\n\n\
176         ## Hook Points\n\n\
177         - `on_session_start` — Called when a new session begins\n\
178         - `on_session_end` — Called when a session ends\n\
179         - `pre_read` — Called before a file is read (receives path via stdin JSON)\n\
180         - `post_compress` — Called after compression (receives stats via stdin JSON)\n\
181         - `on_knowledge_update` — Called when knowledge is updated (receives fact_id via stdin JSON)\n\n\
182         ## Protocol\n\n\
183         Hook data is passed as JSON via stdin. Your command should:\n\
184         1. Read JSON from stdin\n\
185         2. Process the hook\n\
186         3. Write optional JSON response to stdout\n\
187         4. Exit with code 0 on success, non-zero on failure\n"
188    );
189
190    std::fs::write(plugin_dir.join("README.md"), readme)?;
191    Ok(())
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn init_template_creates_files() {
200        let dir = tempfile::tempdir().unwrap();
201        init_plugin_template("test-plugin", dir.path()).unwrap();
202        let plugin_dir = dir.path().join("test-plugin");
203        assert!(plugin_dir.join("plugin.toml").exists());
204        assert!(plugin_dir.join("README.md").exists());
205
206        let manifest = manifest::PluginManifest::from_file(&plugin_dir.join("plugin.toml"));
207        assert!(manifest.is_ok());
208        let m = manifest.unwrap();
209        assert_eq!(m.plugin.name, "test-plugin");
210    }
211
212    #[test]
213    fn fire_hook_with_no_plugins_returns_empty() {
214        let results = PluginManager::fire_hook(&HookPoint::OnSessionStart);
215        assert!(results.is_empty());
216    }
217
218    #[test]
219    fn any_enabled_listener_detects_declared_hook() {
220        use registry::PluginRegistry;
221        use std::fs;
222
223        let dir = tempfile::tempdir().unwrap();
224        let p = dir.path().join("p");
225        fs::create_dir_all(&p).unwrap();
226        fs::write(
227            p.join("plugin.toml"),
228            "[plugin]\nname = \"p\"\nversion = \"1.0.0\"\n\n\
229             [hooks.pre_read]\ncommand = \"echo hi\"\n",
230        )
231        .unwrap();
232
233        let mut reg = PluginRegistry::new(dir.path().to_path_buf());
234        reg.discover();
235
236        assert!(any_enabled_listener(&reg, "pre_read"));
237        assert!(!any_enabled_listener(&reg, "post_compress"));
238    }
239
240    #[test]
241    fn any_enabled_listener_ignores_disabled_plugin() {
242        use registry::PluginRegistry;
243        use std::fs;
244
245        let dir = tempfile::tempdir().unwrap();
246        let p = dir.path().join("p");
247        fs::create_dir_all(&p).unwrap();
248        fs::write(
249            p.join("plugin.toml"),
250            "[plugin]\nname = \"p\"\nversion = \"1.0.0\"\n\n\
251             [hooks.pre_read]\ncommand = \"echo hi\"\n",
252        )
253        .unwrap();
254
255        let mut reg = PluginRegistry::new(dir.path().to_path_buf());
256        reg.discover();
257        reg.disable("p").unwrap();
258
259        assert!(!any_enabled_listener(&reg, "pre_read"));
260    }
261}