Skip to main content

codineer_plugins/
manifest.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::constants::{is_literal_command, MANIFEST_FILE_NAME};
9use crate::error::{PluginError, PluginManifestValidationError};
10use crate::types::{
11    PluginCommandManifest, PluginHooks, PluginLifecycle, PluginManifest, PluginPermission,
12    PluginToolManifest, PluginToolPermission,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16struct RawPluginManifest {
17    pub name: String,
18    pub version: String,
19    pub description: String,
20    #[serde(default)]
21    pub permissions: Vec<String>,
22    #[serde(rename = "defaultEnabled", default)]
23    pub default_enabled: bool,
24    #[serde(default)]
25    pub hooks: PluginHooks,
26    #[serde(default)]
27    pub lifecycle: PluginLifecycle,
28    #[serde(default)]
29    pub tools: Vec<RawPluginToolManifest>,
30    #[serde(default)]
31    pub commands: Vec<PluginCommandManifest>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35struct RawPluginToolManifest {
36    pub name: String,
37    pub description: String,
38    #[serde(rename = "inputSchema")]
39    pub input_schema: Value,
40    pub command: String,
41    #[serde(default)]
42    pub args: Vec<String>,
43    #[serde(
44        rename = "requiredPermission",
45        default = "default_tool_permission_label"
46    )]
47    pub required_permission: String,
48}
49
50fn default_tool_permission_label() -> String {
51    "danger-full-access".to_string()
52}
53
54pub fn load_plugin_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
55    load_manifest_from_directory(root)
56}
57
58fn load_manifest_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
59    let manifest_path = plugin_manifest_path(root)?;
60    load_manifest_from_path(root, &manifest_path)
61}
62
63fn load_manifest_from_path(
64    root: &Path,
65    manifest_path: &Path,
66) -> Result<PluginManifest, PluginError> {
67    let contents = fs::read_to_string(manifest_path).map_err(|error| {
68        PluginError::NotFound(format!(
69            "plugin manifest not found at {}: {error}",
70            manifest_path.display()
71        ))
72    })?;
73    let raw_manifest: RawPluginManifest = serde_json::from_str(&contents)?;
74    build_plugin_manifest(root, raw_manifest)
75}
76
77pub(crate) fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> {
78    let path = root.join(MANIFEST_FILE_NAME);
79    if path.exists() {
80        return Ok(path);
81    }
82    Err(PluginError::NotFound(format!(
83        "plugin manifest not found at {}",
84        path.display(),
85    )))
86}
87
88fn build_plugin_manifest(
89    root: &Path,
90    raw: RawPluginManifest,
91) -> Result<PluginManifest, PluginError> {
92    let mut errors = Vec::new();
93
94    validate_required_manifest_field("name", &raw.name, &mut errors);
95    validate_required_manifest_field("version", &raw.version, &mut errors);
96    validate_required_manifest_field("description", &raw.description, &mut errors);
97
98    let permissions = build_manifest_permissions(&raw.permissions, &mut errors);
99    validate_command_entries(root, raw.hooks.pre_tool_use.iter(), "hook", &mut errors);
100    validate_command_entries(root, raw.hooks.post_tool_use.iter(), "hook", &mut errors);
101    validate_command_entries(
102        root,
103        raw.lifecycle.init.iter(),
104        "lifecycle command",
105        &mut errors,
106    );
107    validate_command_entries(
108        root,
109        raw.lifecycle.shutdown.iter(),
110        "lifecycle command",
111        &mut errors,
112    );
113    let tools = build_manifest_tools(root, raw.tools, &mut errors);
114    let commands = build_manifest_commands(root, raw.commands, &mut errors);
115
116    if !errors.is_empty() {
117        return Err(PluginError::ManifestValidation(errors));
118    }
119
120    Ok(PluginManifest {
121        name: raw.name,
122        version: raw.version,
123        description: raw.description,
124        permissions,
125        default_enabled: raw.default_enabled,
126        hooks: raw.hooks,
127        lifecycle: raw.lifecycle,
128        tools,
129        commands,
130    })
131}
132
133fn validate_required_manifest_field(
134    field: &'static str,
135    value: &str,
136    errors: &mut Vec<PluginManifestValidationError>,
137) {
138    if value.trim().is_empty() {
139        errors.push(PluginManifestValidationError::EmptyField { field });
140    }
141}
142
143fn build_manifest_permissions(
144    permissions: &[String],
145    errors: &mut Vec<PluginManifestValidationError>,
146) -> Vec<PluginPermission> {
147    let mut seen = BTreeSet::new();
148    let mut validated = Vec::new();
149
150    for permission in permissions {
151        let permission = permission.trim();
152        if permission.is_empty() {
153            errors.push(PluginManifestValidationError::EmptyEntryField {
154                kind: "permission",
155                field: "value",
156                name: None,
157            });
158            continue;
159        }
160        if !seen.insert(permission.to_string()) {
161            errors.push(PluginManifestValidationError::DuplicatePermission {
162                permission: permission.to_string(),
163            });
164            continue;
165        }
166        match PluginPermission::parse(permission) {
167            Some(permission) => validated.push(permission),
168            None => errors.push(PluginManifestValidationError::InvalidPermission {
169                permission: permission.to_string(),
170            }),
171        }
172    }
173
174    validated
175}
176
177fn build_manifest_tools(
178    root: &Path,
179    tools: Vec<RawPluginToolManifest>,
180    errors: &mut Vec<PluginManifestValidationError>,
181) -> Vec<PluginToolManifest> {
182    let mut seen = BTreeSet::new();
183    let mut validated = Vec::new();
184
185    for tool in tools {
186        let name = tool.name.trim().to_string();
187        if name.is_empty() {
188            errors.push(PluginManifestValidationError::EmptyEntryField {
189                kind: "tool",
190                field: "name",
191                name: None,
192            });
193            continue;
194        }
195        if !seen.insert(name.clone()) {
196            errors.push(PluginManifestValidationError::DuplicateEntry { kind: "tool", name });
197            continue;
198        }
199        if tool.description.trim().is_empty() {
200            errors.push(PluginManifestValidationError::EmptyEntryField {
201                kind: "tool",
202                field: "description",
203                name: Some(name.clone()),
204            });
205        }
206        if tool.command.trim().is_empty() {
207            errors.push(PluginManifestValidationError::EmptyEntryField {
208                kind: "tool",
209                field: "command",
210                name: Some(name.clone()),
211            });
212        } else {
213            validate_command_entry(root, &tool.command, "tool", errors);
214        }
215        if !tool.input_schema.is_object() {
216            errors.push(PluginManifestValidationError::InvalidToolInputSchema {
217                tool_name: name.clone(),
218            });
219        }
220        let Some(required_permission) =
221            PluginToolPermission::parse(tool.required_permission.trim())
222        else {
223            errors.push(
224                PluginManifestValidationError::InvalidToolRequiredPermission {
225                    tool_name: name.clone(),
226                    permission: tool.required_permission.trim().to_string(),
227                },
228            );
229            continue;
230        };
231
232        validated.push(PluginToolManifest {
233            name,
234            description: tool.description,
235            input_schema: tool.input_schema,
236            command: tool.command,
237            args: tool.args,
238            required_permission,
239        });
240    }
241
242    validated
243}
244
245fn build_manifest_commands(
246    root: &Path,
247    commands: Vec<PluginCommandManifest>,
248    errors: &mut Vec<PluginManifestValidationError>,
249) -> Vec<PluginCommandManifest> {
250    let mut seen = BTreeSet::new();
251    let mut validated = Vec::new();
252
253    for command in commands {
254        let name = command.name.trim().to_string();
255        if name.is_empty() {
256            errors.push(PluginManifestValidationError::EmptyEntryField {
257                kind: "command",
258                field: "name",
259                name: None,
260            });
261            continue;
262        }
263        if !seen.insert(name.clone()) {
264            errors.push(PluginManifestValidationError::DuplicateEntry {
265                kind: "command",
266                name,
267            });
268            continue;
269        }
270        if command.description.trim().is_empty() {
271            errors.push(PluginManifestValidationError::EmptyEntryField {
272                kind: "command",
273                field: "description",
274                name: Some(name.clone()),
275            });
276        }
277        if command.command.trim().is_empty() {
278            errors.push(PluginManifestValidationError::EmptyEntryField {
279                kind: "command",
280                field: "command",
281                name: Some(name.clone()),
282            });
283        } else {
284            validate_command_entry(root, &command.command, "command", errors);
285        }
286        validated.push(command);
287    }
288
289    validated
290}
291
292fn validate_command_entries<'a>(
293    root: &Path,
294    entries: impl Iterator<Item = &'a String>,
295    kind: &'static str,
296    errors: &mut Vec<PluginManifestValidationError>,
297) {
298    for entry in entries {
299        validate_command_entry(root, entry, kind, errors);
300    }
301}
302
303fn validate_command_entry(
304    root: &Path,
305    entry: &str,
306    kind: &'static str,
307    errors: &mut Vec<PluginManifestValidationError>,
308) {
309    if entry.trim().is_empty() {
310        errors.push(PluginManifestValidationError::EmptyEntryField {
311            kind,
312            field: "command",
313            name: None,
314        });
315        return;
316    }
317    if is_literal_command(entry) {
318        return;
319    }
320
321    let path = if Path::new(entry).is_absolute() {
322        PathBuf::from(entry)
323    } else {
324        root.join(entry)
325    };
326    if !path.exists() {
327        errors.push(PluginManifestValidationError::MissingPath { kind, path });
328    }
329}