Skip to main content

lean_ctx/core/plugins/
mod.rs

1pub mod executor;
2pub mod manifest;
3pub mod registry;
4
5use executor::{execute_hooks_for_point, HookPoint, HookResult};
6use registry::PluginRegistry;
7use std::sync::Mutex;
8use std::sync::OnceLock;
9
10static GLOBAL_REGISTRY: OnceLock<Mutex<PluginRegistry>> = OnceLock::new();
11
12pub struct PluginManager;
13
14impl PluginManager {
15    pub fn init() {
16        let _ = GLOBAL_REGISTRY.get_or_init(|| {
17            let mut reg = PluginRegistry::from_default_dir();
18            let errors = reg.discover();
19            for err in &errors {
20                tracing::warn!(
21                    "plugin discovery error at {}: {}",
22                    err.path.display(),
23                    err.error
24                );
25            }
26            Mutex::new(reg)
27        });
28    }
29
30    pub fn with_registry<F, R>(f: F) -> Option<R>
31    where
32        F: FnOnce(&PluginRegistry) -> R,
33    {
34        GLOBAL_REGISTRY
35            .get()
36            .and_then(|m| m.lock().ok())
37            .map(|reg| f(&reg))
38    }
39
40    pub fn with_registry_mut<F, R>(f: F) -> Option<R>
41    where
42        F: FnOnce(&mut PluginRegistry) -> R,
43    {
44        GLOBAL_REGISTRY
45            .get()
46            .and_then(|m| m.lock().ok())
47            .map(|mut reg| f(&mut reg))
48    }
49
50    pub fn fire_hook(hook: &HookPoint) -> Vec<HookResult> {
51        Self::with_registry(|reg| {
52            let plugins: Vec<_> = reg.enabled_plugins();
53            execute_hooks_for_point(&plugins, hook)
54        })
55        .unwrap_or_default()
56    }
57
58    pub fn fire_hook_background(hook: HookPoint) {
59        std::thread::spawn(move || {
60            let results = Self::fire_hook(&hook);
61            for r in &results {
62                if !r.success {
63                    tracing::warn!(
64                        "plugin hook failed: {} - {}",
65                        r.plugin_name,
66                        r.error.as_deref().unwrap_or("unknown")
67                    );
68                }
69            }
70        });
71    }
72}
73
74pub fn init_plugin_template(name: &str, dir: &std::path::Path) -> std::io::Result<()> {
75    let plugin_dir = dir.join(name);
76    std::fs::create_dir_all(&plugin_dir)?;
77
78    let manifest = format!(
79        r#"[plugin]
80name = "{name}"
81version = "0.1.0"
82description = "Description of what this plugin does"
83author = "Your Name"
84
85[hooks.on_session_start]
86command = "{name} start"
87timeout_ms = 5000
88
89[hooks.on_session_end]
90command = "{name} stop"
91
92# [hooks.pre_read]
93# command = "{name} pre-read"
94# timeout_ms = 2000
95
96# [hooks.post_compress]
97# command = "{name} post-compress"
98
99# [hooks.on_knowledge_update]
100# command = "{name} knowledge-updated"
101"#
102    );
103
104    std::fs::write(plugin_dir.join("plugin.toml"), manifest)?;
105
106    let readme = format!(
107        "# {name}\n\n\
108         A lean-ctx plugin.\n\n\
109         ## Installation\n\n\
110         Copy this directory to `~/.config/lean-ctx/plugins/{name}/`\n\n\
111         ## Hook Points\n\n\
112         - `on_session_start` — Called when a new session begins\n\
113         - `on_session_end` — Called when a session ends\n\
114         - `pre_read` — Called before a file is read (receives path via stdin JSON)\n\
115         - `post_compress` — Called after compression (receives stats via stdin JSON)\n\
116         - `on_knowledge_update` — Called when knowledge is updated (receives fact_id via stdin JSON)\n\n\
117         ## Protocol\n\n\
118         Hook data is passed as JSON via stdin. Your command should:\n\
119         1. Read JSON from stdin\n\
120         2. Process the hook\n\
121         3. Write optional JSON response to stdout\n\
122         4. Exit with code 0 on success, non-zero on failure\n"
123    );
124
125    std::fs::write(plugin_dir.join("README.md"), readme)?;
126    Ok(())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn init_template_creates_files() {
135        let dir = tempfile::tempdir().unwrap();
136        init_plugin_template("test-plugin", dir.path()).unwrap();
137        let plugin_dir = dir.path().join("test-plugin");
138        assert!(plugin_dir.join("plugin.toml").exists());
139        assert!(plugin_dir.join("README.md").exists());
140
141        let manifest = manifest::PluginManifest::from_file(&plugin_dir.join("plugin.toml"));
142        assert!(manifest.is_ok());
143        let m = manifest.unwrap();
144        assert_eq!(m.plugin.name, "test-plugin");
145    }
146
147    #[test]
148    fn fire_hook_with_no_plugins_returns_empty() {
149        let results = PluginManager::fire_hook(&HookPoint::OnSessionStart);
150        assert!(results.is_empty());
151    }
152}