Skip to main content

rightkit_agent_engine_api_integrations/
discovery.rs

1//! Pure local-plugin discovery & middleware projection.
2
3use std::path::{Path, PathBuf};
4
5/// A plugin folder whose `plugin.json` could not be read or parsed.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct BrokenPlugin {
8    pub path: PathBuf,
9    pub error: String,
10}
11
12/// Every plugin folder under the plugin directory: the manifests that parse, and the ones
13/// that do not, each with its path and error (contract R10: one bad file never hides the
14/// others). Hidden folders are not plugins. A missing directory holds no plugins.
15#[derive(Debug, Clone, Default)]
16pub struct PluginDiscovery {
17    pub plugins: Vec<rightkit_agent_plugins::PluginManifest>,
18    pub broken: Vec<BrokenPlugin>,
19}
20
21pub fn discover_plugin_report(dir: &Path) -> Result<PluginDiscovery, String> {
22    let entries = match std::fs::read_dir(dir) {
23        Ok(entries) => entries,
24        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
25            return Ok(PluginDiscovery::default())
26        }
27        Err(error) => return Err(error.to_string()),
28    };
29    let mut report = PluginDiscovery::default();
30    for entry in entries {
31        let entry = entry.map_err(|error| error.to_string())?;
32        let root = entry.path();
33        let hidden = entry.file_name().to_string_lossy().starts_with('.');
34        if hidden || !root.is_dir() {
35            continue;
36        }
37        let manifest_path = root.join("plugin.json");
38        if !manifest_path.exists() {
39            continue;
40        }
41        let parsed = std::fs::read_to_string(&manifest_path)
42            .map_err(|error| error.to_string())
43            .and_then(|raw| {
44                rightkit_agent_plugins::PluginManifest::from_json(&raw)
45                    .map_err(|error| error.to_string())
46            });
47        match parsed {
48            Ok(manifest) => {
49                if report.plugins.iter().all(|plugin| plugin.id != manifest.id) {
50                    report.plugins.push(manifest);
51                }
52            }
53            Err(error) => report.broken.push(BrokenPlugin {
54                path: manifest_path,
55                error,
56            }),
57        }
58    }
59    report.plugins.sort_by(|left, right| {
60        left.id
61            .cmp(&right.id)
62            .then_with(|| left.version.cmp(&right.version))
63    });
64    report
65        .broken
66        .sort_by(|left, right| left.path.cmp(&right.path));
67    Ok(report)
68}
69
70/// The plugins whose manifests parse. A broken manifest is skipped here and reported by
71/// [`discover_plugin_report`].
72pub fn discover_plugins(dir: &Path) -> Result<Vec<rightkit_agent_plugins::PluginManifest>, String> {
73    Ok(discover_plugin_report(dir)?.plugins)
74}
75
76pub fn plugin_manifests_with_roots(
77    dir: &Path,
78) -> Vec<(rightkit_agent_plugins::PluginManifest, PathBuf)> {
79    let Ok(entries) = std::fs::read_dir(dir) else {
80        return Vec::new();
81    };
82    let mut manifests = Vec::new();
83    for entry in entries.flatten() {
84        let root = entry.path();
85        if !root.is_dir() {
86            continue;
87        }
88        let manifest_path = root.join("plugin.json");
89        let Ok(raw) = std::fs::read_to_string(&manifest_path) else {
90            continue;
91        };
92        let Ok(manifest) = rightkit_agent_plugins::PluginManifest::from_json(&raw) else {
93            continue;
94        };
95        manifests.push((manifest, root));
96    }
97    manifests.sort_by(|(left, _), (right, _)| {
98        left.id
99            .cmp(&right.id)
100            .then_with(|| left.version.cmp(&right.version))
101    });
102    manifests
103}
104
105pub fn provider_extension_documents(dir: &Path) -> Result<Vec<String>, String> {
106    let mut documents = Vec::new();
107    for (manifest, root) in plugin_manifests_with_roots(dir) {
108        if !manifest.enabled {
109            continue;
110        }
111        let path = root.join("provider.json");
112        if path.exists() {
113            documents.push(std::fs::read_to_string(path).map_err(|error| error.to_string())?);
114        }
115    }
116    Ok(documents)
117}
118
119pub fn local_plugin_middleware_definitions(
120    dir: &Path,
121) -> Vec<rightkit_agent_engine_runtime_middleware::MiddlewareDefinition> {
122    let mut definitions = Vec::new();
123    for (manifest, _) in plugin_manifests_with_roots(dir) {
124        if !manifest.enabled {
125            continue;
126        }
127        for contribution in manifest.middleware {
128            if let Some(definition) = plugin_middleware_definition(&manifest.id, contribution) {
129                definitions.push(definition);
130            }
131        }
132    }
133    definitions.sort_by(|left, right| {
134        left.order
135            .cmp(&right.order)
136            .then_with(|| left.id.cmp(&right.id))
137    });
138    definitions
139}
140
141fn plugin_middleware_definition(
142    plugin_id: &str,
143    contribution: rightkit_agent_plugins::PluginMiddlewareContribution,
144) -> Option<rightkit_agent_engine_runtime_middleware::MiddlewareDefinition> {
145    use rightkit_agent_engine_runtime_middleware::{
146        MiddlewareCondition, MiddlewareDefinition, MiddlewareSource,
147    };
148    let phase = parse_middleware_phase(&contribution.phase)?;
149    let effect = parse_middleware_effect(&contribution.effect)?;
150    let id = contribution.id.trim();
151    if id.is_empty() {
152        return None;
153    }
154    Some(MiddlewareDefinition {
155        id: format!("plugin.{plugin_id}.{id}"),
156        title: if contribution.title.trim().is_empty() {
157            format!("{plugin_id}: {id}")
158        } else {
159            contribution.title
160        },
161        source: MiddlewareSource::LocalPlugin,
162        phase,
163        effect,
164        tool: contribution
165            .tool
166            .map(|tool| tool.trim().to_string())
167            .filter(|tool| !tool.is_empty()),
168        requires_toolset: contribution
169            .requires_toolset
170            .map(|toolset| toolset.trim().to_string())
171            .filter(|toolset| !toolset.is_empty()),
172        condition: contribution.condition.map(|condition| MiddlewareCondition {
173            argument: condition
174                .argument
175                .map(|argument| argument.trim().to_string())
176                .filter(|argument| !argument.is_empty()),
177            contains: condition
178                .contains
179                .map(|contains| contains.trim().to_string())
180                .filter(|contains| !contains.is_empty()),
181            equals: condition.equals,
182        }),
183        mutate_args: contribution.mutate_args,
184        reason: contribution
185            .reason
186            .map(|reason| reason.trim().to_string())
187            .filter(|reason| !reason.is_empty()),
188        enabled: contribution.enabled.unwrap_or(true),
189        order: contribution.order.unwrap_or(100),
190        description: contribution.description,
191    })
192}
193
194fn parse_middleware_phase(
195    value: &str,
196) -> Option<rightkit_agent_engine_runtime_middleware::MiddlewarePhase> {
197    use rightkit_agent_engine_runtime_middleware::MiddlewarePhase;
198    match value {
199        "sessionStart" => Some(MiddlewarePhase::SessionStart),
200        "preLlmCall" => Some(MiddlewarePhase::PreLlmCall),
201        "postLlmCall" => Some(MiddlewarePhase::PostLlmCall),
202        "preToolCall" => Some(MiddlewarePhase::PreToolCall),
203        "postToolCall" => Some(MiddlewarePhase::PostToolCall),
204        "approvalRequested" => Some(MiddlewarePhase::ApprovalRequested),
205        "subagentStart" => Some(MiddlewarePhase::SubagentStart),
206        "subagentStop" => Some(MiddlewarePhase::SubagentStop),
207        "scheduledJobStart" => Some(MiddlewarePhase::ScheduledJobStart),
208        "scheduledJobEnd" => Some(MiddlewarePhase::ScheduledJobEnd),
209        "sessionEnd" => Some(MiddlewarePhase::SessionEnd),
210        _ => None,
211    }
212}
213
214fn parse_middleware_effect(
215    value: &str,
216) -> Option<rightkit_agent_engine_runtime_middleware::MiddlewareEffect> {
217    use rightkit_agent_engine_runtime_middleware::MiddlewareEffect;
218    match value {
219        "observe" => Some(MiddlewareEffect::Observe),
220        "mutateArgs" => Some(MiddlewareEffect::MutateArgs),
221        "requireApproval" => Some(MiddlewareEffect::RequireApproval),
222        "deny" => Some(MiddlewareEffect::Deny),
223        _ => None,
224    }
225}