1use std::collections::BTreeSet;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6pub const MANIFEST_FILE_NAME: &str = "plugin.json";
7pub const MANIFEST_RELATIVE_PATH: &str = ".pleiades-plugin/plugin.json";
8
9#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
10pub struct PluginHooks {
11 #[serde(rename = "PreToolUse", default)]
12 pub pre_tool_use: Vec<String>,
13 #[serde(rename = "PostToolUse", default)]
14 pub post_tool_use: Vec<String>,
15 #[serde(rename = "PostToolUseFailure", default)]
16 pub post_tool_use_failure: Vec<String>,
17}
18
19impl PluginHooks {
20 pub fn is_empty(&self) -> bool {
21 self.pre_tool_use.is_empty()
22 && self.post_tool_use.is_empty()
23 && self.post_tool_use_failure.is_empty()
24 }
25
26 pub fn merged_with(&self, other: &Self) -> Self {
27 let mut merged = self.clone();
28 merged
29 .pre_tool_use
30 .extend(other.pre_tool_use.iter().cloned());
31 merged
32 .post_tool_use
33 .extend(other.post_tool_use.iter().cloned());
34 merged
35 .post_tool_use_failure
36 .extend(other.post_tool_use_failure.iter().cloned());
37 merged
38 }
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42pub struct PluginLifecycle {
43 #[serde(rename = "Init", default)]
44 pub init: Vec<String>,
45 #[serde(rename = "Shutdown", default)]
46 pub shutdown: Vec<String>,
47}
48
49impl PluginLifecycle {
50 pub fn is_empty(&self) -> bool {
51 self.init.is_empty() && self.shutdown.is_empty()
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum PluginPermission {
58 Read,
59 Write,
60 Execute,
61}
62
63impl PluginPermission {
64 pub fn as_str(self) -> &'static str {
65 match self {
66 Self::Read => "read",
67 Self::Write => "write",
68 Self::Execute => "execute",
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct PluginToolManifest {
75 pub name: String,
76 pub description: String,
77 #[serde(rename = "inputSchema")]
78 pub input_schema: serde_json::Value,
79 pub command: String,
80 #[serde(default)]
81 pub args: Vec<String>,
82 #[serde(rename = "requiredPermission", default = "default_tool_permission")]
83 pub required_permission: PluginToolPermission,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum PluginToolPermission {
89 ReadOnly,
90 WorkspaceWrite,
91 DangerFullAccess,
92}
93
94impl PluginToolPermission {
95 pub fn as_str(self) -> &'static str {
96 match self {
97 Self::ReadOnly => "read-only",
98 Self::WorkspaceWrite => "workspace-write",
99 Self::DangerFullAccess => "danger-full-access",
100 }
101 }
102}
103
104fn default_tool_permission() -> PluginToolPermission {
105 PluginToolPermission::DangerFullAccess
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct PluginCommandManifest {
110 pub name: String,
111 pub description: String,
112 pub command: String,
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct PluginManifest {
117 pub name: String,
118 pub version: String,
119 pub description: String,
120 #[serde(default)]
121 pub permissions: Vec<String>,
122 #[serde(rename = "defaultEnabled", default)]
123 pub default_enabled: bool,
124 #[serde(default)]
125 pub hooks: PluginHooks,
126 #[serde(default)]
127 pub lifecycle: PluginLifecycle,
128 #[serde(default)]
129 pub tools: Vec<PluginToolManifest>,
130 #[serde(default)]
131 pub commands: Vec<PluginCommandManifest>,
132}
133
134impl PluginManifest {
135 pub fn validate(&self, root: &Path) -> Result<(), PluginError> {
136 let mut errors = Vec::new();
137
138 if self.name.trim().is_empty() {
139 errors.push(PluginErrorKind::EmptyField { field: "name" });
140 }
141 if self.version.trim().is_empty() {
142 errors.push(PluginErrorKind::EmptyField { field: "version" });
143 }
144 if self.description.trim().is_empty() {
145 errors.push(PluginErrorKind::EmptyField {
146 field: "description",
147 });
148 }
149
150 let mut seen_perms = BTreeSet::new();
151 for perm in &self.permissions {
152 if !seen_perms.insert(perm.clone()) {
153 errors.push(PluginErrorKind::DuplicatePermission {
154 permission: perm.clone(),
155 });
156 }
157 match perm.as_str() {
158 "read" | "write" | "execute" => {}
159 other => errors.push(PluginErrorKind::InvalidPermission {
160 permission: other.to_string(),
161 }),
162 }
163 }
164
165 Self::validate_paths(root, &self.hooks.pre_tool_use, "hook", &mut errors);
166 Self::validate_paths(root, &self.hooks.post_tool_use, "hook", &mut errors);
167 Self::validate_paths(root, &self.hooks.post_tool_use_failure, "hook", &mut errors);
168 Self::validate_paths(root, &self.lifecycle.init, "lifecycle", &mut errors);
169 Self::validate_paths(root, &self.lifecycle.shutdown, "lifecycle", &mut errors);
170
171 if !errors.is_empty() {
172 return Err(PluginError::Validation(errors));
173 }
174 Ok(())
175 }
176
177 fn validate_paths(
178 root: &Path,
179 paths: &[String],
180 kind: &str,
181 errors: &mut Vec<PluginErrorKind>,
182 ) {
183 for path in paths {
184 let full_path = if Path::new(path).is_absolute() {
185 PathBuf::from(path)
186 } else {
187 root.join(path)
188 };
189 if !full_path.exists() {
190 errors.push(PluginErrorKind::MissingPath {
191 kind: kind.to_string(),
192 path: full_path,
193 });
194 }
195 }
196 }
197
198 pub fn load_from_directory(root: &Path) -> Result<Self, PluginError> {
199 let manifest_path = find_manifest_path(root)?;
200 let content = std::fs::read_to_string(&manifest_path).map_err(|e| {
201 PluginError::Io(std::io::Error::new(
202 std::io::ErrorKind::NotFound,
203 format!(
204 "plugin manifest not found at {}: {e}",
205 manifest_path.display()
206 ),
207 ))
208 })?;
209
210 let manifest: Self = serde_json::from_str(&content)?;
211 manifest.validate(root)?;
212 Ok(manifest)
213 }
214}
215
216fn find_manifest_path(root: &Path) -> Result<PathBuf, PluginError> {
217 let direct = root.join(MANIFEST_FILE_NAME);
218 if direct.exists() {
219 return Ok(direct);
220 }
221 let packaged = root.join(MANIFEST_RELATIVE_PATH);
222 if packaged.exists() {
223 return Ok(packaged);
224 }
225 Err(PluginError::Io(std::io::Error::new(
226 std::io::ErrorKind::NotFound,
227 format!(
228 "plugin manifest not found at {} or {}",
229 direct.display(),
230 packaged.display()
231 ),
232 )))
233}
234
235#[derive(Debug)]
236pub enum PluginError {
237 Io(std::io::Error),
238 Json(serde_json::Error),
239 Validation(Vec<PluginErrorKind>),
240 NotFound(String),
241 CommandFailed(String),
242}
243
244impl std::fmt::Display for PluginError {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 match self {
247 Self::Io(e) => write!(f, "{e}"),
248 Self::Json(e) => write!(f, "{e}"),
249 Self::Validation(errors) => {
250 for (i, e) in errors.iter().enumerate() {
251 if i > 0 {
252 write!(f, "; ")?;
253 }
254 write!(f, "{e}")?;
255 }
256 Ok(())
257 }
258 Self::NotFound(msg) | Self::CommandFailed(msg) => write!(f, "{msg}"),
259 }
260 }
261}
262
263impl std::error::Error for PluginError {}
264
265impl From<std::io::Error> for PluginError {
266 fn from(e: std::io::Error) -> Self {
267 Self::Io(e)
268 }
269}
270
271impl From<serde_json::Error> for PluginError {
272 fn from(e: serde_json::Error) -> Self {
273 Self::Json(e)
274 }
275}
276
277#[derive(Debug, Clone)]
278pub enum PluginErrorKind {
279 EmptyField { field: &'static str },
280 InvalidPermission { permission: String },
281 DuplicatePermission { permission: String },
282 MissingPath { kind: String, path: PathBuf },
283}
284
285impl std::fmt::Display for PluginErrorKind {
286 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 match self {
288 Self::EmptyField { field } => write!(f, "plugin manifest {field} cannot be empty"),
289 Self::InvalidPermission { permission } => {
290 write!(
291 f,
292 "invalid permission `{permission}`, must be read/write/execute"
293 )
294 }
295 Self::DuplicatePermission { permission } => {
296 write!(f, "duplicate permission `{permission}`")
297 }
298 Self::MissingPath { kind, path } => {
299 write!(f, "{kind} path `{}` does not exist", path.display())
300 }
301 }
302 }
303}