1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct PluginAuthor {
13 pub name: String,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub email: Option<String>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub url: Option<String>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct CommandMetadata {
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub source: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub content: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub description: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub argument_hint: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub model: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub allowed_tools: Option<Vec<String>>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum CommandAvailability {
42 ClaudeAi,
44 Console,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum CommandResultDisplay {
52 Skip,
54 System,
56 User,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(tag = "type", rename_all = "snake_case")]
63pub enum CommandResult {
64 Text { value: String },
66 Skip,
68 Compact {
70 #[serde(skip_serializing_if = "Option::is_none")]
71 display_text: Option<String>,
72 },
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum CommandSource {
79 Builtin,
81 Skills,
83 Plugin,
85 Managed,
87 Bundled,
89 Mcp,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct PluginManifest {
97 pub name: String,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub version: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub description: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub author: Option<PluginAuthor>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub homepage: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub repository: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub license: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub keywords: Option<Vec<String>>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub dependencies: Option<Vec<String>>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub commands: Option<serde_json::Value>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub agents: Option<serde_json::Value>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub skills: Option<serde_json::Value>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub hooks: Option<serde_json::Value>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub output_styles: Option<serde_json::Value>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub channels: Option<serde_json::Value>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub mcp_servers: Option<serde_json::Value>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub lsp_servers: Option<serde_json::Value>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub settings: Option<HashMap<String, serde_json::Value>>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub user_config: Option<HashMap<String, serde_json::Value>>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct PluginRepository {
142 pub url: String,
143 pub branch: String,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub last_updated: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub commit_sha: Option<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153pub struct PluginConfig {
154 pub repositories: HashMap<String, PluginRepository>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "kebab-case")]
160pub enum PluginComponent {
161 Commands,
162 Agents,
163 Skills,
164 Hooks,
165 OutputStyles,
166}
167
168impl std::fmt::Display for PluginComponent {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 match self {
171 PluginComponent::Commands => write!(f, "commands"),
172 PluginComponent::Agents => write!(f, "agents"),
173 PluginComponent::Skills => write!(f, "skills"),
174 PluginComponent::Hooks => write!(f, "hooks"),
175 PluginComponent::OutputStyles => write!(f, "output-styles"),
176 }
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct LoadedPlugin {
184 pub name: String,
185 pub manifest: PluginManifest,
186 pub path: String,
187 pub source: String,
188 pub repository: String,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub enabled: Option<bool>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub is_builtin: Option<bool>,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub sha: Option<String>,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub commands_path: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub commands_paths: Option<Vec<String>>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub commands_metadata: Option<HashMap<String, CommandMetadata>>,
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub agents_path: Option<String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub agents_paths: Option<Vec<String>>,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 pub skills_path: Option<String>,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 pub skills_paths: Option<Vec<String>>,
213 #[serde(skip_serializing_if = "Option::is_none")]
214 pub output_styles_path: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none")]
216 pub output_styles_paths: Option<Vec<String>>,
217 #[serde(skip_serializing_if = "Option::is_none")]
218 pub hooks_config: Option<serde_json::Value>,
219 #[serde(skip_serializing_if = "Option::is_none")]
220 pub mcp_servers: Option<HashMap<String, serde_json::Value>>,
221 #[serde(skip_serializing_if = "Option::is_none")]
222 pub lsp_servers: Option<HashMap<String, serde_json::Value>>,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub settings: Option<HashMap<String, serde_json::Value>>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(tag = "type", rename_all = "kebab-case")]
230pub enum PluginError {
231 PathNotFound {
233 source: String,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 plugin: Option<String>,
236 path: String,
237 component: PluginComponent,
238 },
239 GitAuthFailed {
241 source: String,
242 #[serde(skip_serializing_if = "Option::is_none")]
243 plugin: Option<String>,
244 git_url: String,
245 auth_type: String,
246 },
247 GitTimeout {
249 source: String,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 plugin: Option<String>,
252 git_url: String,
253 operation: String,
254 },
255 NetworkError {
257 source: String,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 plugin: Option<String>,
260 url: String,
261 #[serde(skip_serializing_if = "Option::is_none")]
262 details: Option<String>,
263 },
264 ManifestParseError {
266 source: String,
267 #[serde(skip_serializing_if = "Option::is_none")]
268 plugin: Option<String>,
269 manifest_path: String,
270 parse_error: String,
271 },
272 ManifestValidationError {
274 source: String,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 plugin: Option<String>,
277 manifest_path: String,
278 validation_errors: Vec<String>,
279 },
280 PluginNotFound {
282 source: String,
283 plugin_id: String,
284 marketplace: String,
285 },
286 MarketplaceNotFound {
288 source: String,
289 marketplace: String,
290 available_marketplaces: Vec<String>,
291 },
292 MarketplaceLoadFailed {
294 source: String,
295 marketplace: String,
296 reason: String,
297 },
298 McpConfigInvalid {
300 source: String,
301 plugin: String,
302 server_name: String,
303 validation_error: String,
304 },
305 McpServerSuppressedDuplicate {
307 source: String,
308 plugin: String,
309 server_name: String,
310 duplicate_of: String,
311 },
312 LspConfigInvalid {
314 source: String,
315 plugin: String,
316 server_name: String,
317 validation_error: String,
318 },
319 HookLoadFailed {
321 source: String,
322 plugin: String,
323 hook_path: String,
324 reason: String,
325 },
326 ComponentLoadFailed {
328 source: String,
329 plugin: String,
330 component: PluginComponent,
331 path: String,
332 reason: String,
333 },
334 McpbDownloadFailed {
336 source: String,
337 plugin: String,
338 url: String,
339 reason: String,
340 },
341 McpbExtractFailed {
343 source: String,
344 plugin: String,
345 mcpb_path: String,
346 reason: String,
347 },
348 McpbInvalidManifest {
350 source: String,
351 plugin: String,
352 mcpb_path: String,
353 validation_error: String,
354 },
355 LspServerStartFailed {
357 source: String,
358 plugin: String,
359 server_name: String,
360 reason: String,
361 },
362 LspServerCrashed {
364 source: String,
365 plugin: String,
366 server_name: String,
367 exit_code: Option<i32>,
368 #[serde(skip_serializing_if = "Option::is_none")]
369 signal: Option<String>,
370 },
371 LspRequestTimeout {
373 source: String,
374 plugin: String,
375 server_name: String,
376 method: String,
377 timeout_ms: u64,
378 },
379 LspRequestFailed {
381 source: String,
382 plugin: String,
383 server_name: String,
384 method: String,
385 error: String,
386 },
387 MarketplaceBlockedByPolicy {
389 source: String,
390 #[serde(skip_serializing_if = "Option::is_none")]
391 plugin: Option<String>,
392 marketplace: String,
393 #[serde(skip_serializing_if = "Option::is_none")]
395 blocked_by_blocklist: Option<bool>,
396 allowed_sources: Vec<String>,
398 },
399 DependencyUnsatisfied {
401 source: String,
402 plugin: String,
403 dependency: String,
404 reason: String,
405 },
406 PluginCacheMiss {
408 source: String,
409 plugin: String,
410 install_path: String,
411 },
412 GenericError {
414 source: String,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 plugin: Option<String>,
417 error: String,
418 },
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423#[serde(rename_all = "camelCase")]
424pub struct PluginLoadResult {
425 pub enabled: Vec<LoadedPlugin>,
426 pub disabled: Vec<LoadedPlugin>,
427 pub errors: Vec<PluginError>,
428}
429
430pub fn get_plugin_error_message(error: &PluginError) -> String {
432 match error {
433 PluginError::GenericError { error, .. } => error.clone(),
434 PluginError::PathNotFound {
435 path, component, ..
436 } => {
437 format!("Path not found: {} ({})", path, component)
438 }
439 PluginError::GitAuthFailed {
440 git_url, auth_type, ..
441 } => {
442 format!("Git authentication failed ({}): {}", auth_type, git_url)
443 }
444 PluginError::GitTimeout {
445 git_url, operation, ..
446 } => {
447 format!("Git {} timeout: {}", operation, git_url)
448 }
449 PluginError::NetworkError { url, details, .. } => {
450 if let Some(d) = details {
451 format!("Network error: {} - {}", url, d)
452 } else {
453 format!("Network error: {}", url)
454 }
455 }
456 PluginError::ManifestParseError { parse_error, .. } => {
457 format!("Manifest parse error: {}", parse_error)
458 }
459 PluginError::ManifestValidationError {
460 validation_errors, ..
461 } => {
462 format!(
463 "Manifest validation failed: {}",
464 validation_errors.join(", ")
465 )
466 }
467 PluginError::PluginNotFound {
468 plugin_id,
469 marketplace,
470 ..
471 } => {
472 format!(
473 "Plugin {} not found in marketplace {}",
474 plugin_id, marketplace
475 )
476 }
477 PluginError::MarketplaceNotFound { marketplace, .. } => {
478 format!("Marketplace {} not found", marketplace)
479 }
480 PluginError::MarketplaceLoadFailed {
481 marketplace,
482 reason,
483 ..
484 } => {
485 format!("Marketplace {} failed to load: {}", marketplace, reason)
486 }
487 PluginError::McpConfigInvalid {
488 server_name,
489 validation_error,
490 ..
491 } => {
492 format!("MCP server {} invalid: {}", server_name, validation_error)
493 }
494 PluginError::McpServerSuppressedDuplicate {
495 server_name,
496 duplicate_of,
497 ..
498 } => {
499 let dup = if duplicate_of.starts_with("plugin:") {
500 format!(
501 "server provided by plugin \"{}\"",
502 duplicate_of.strip_prefix("plugin:").unwrap_or("?")
503 )
504 } else {
505 format!("already-configured \"{}\"", duplicate_of)
506 };
507 format!(
508 "MCP server \"{}\" skipped — same command/URL as {}",
509 server_name, dup
510 )
511 }
512 PluginError::HookLoadFailed { reason, .. } => {
513 format!("Hook load failed: {}", reason)
514 }
515 PluginError::ComponentLoadFailed {
516 component,
517 path,
518 reason,
519 ..
520 } => {
521 format!("{} load failed from {}: {}", component, path, reason)
522 }
523 PluginError::McpbDownloadFailed { url, reason, .. } => {
524 format!("Failed to download MCPB from {}: {}", url, reason)
525 }
526 PluginError::McpbExtractFailed {
527 mcpb_path, reason, ..
528 } => {
529 format!("Failed to extract MCPB {}: {}", mcpb_path, reason)
530 }
531 PluginError::McpbInvalidManifest {
532 mcpb_path,
533 validation_error,
534 ..
535 } => {
536 format!(
537 "MCPB manifest invalid at {}: {}",
538 mcpb_path, validation_error
539 )
540 }
541 PluginError::LspConfigInvalid {
542 plugin,
543 server_name,
544 validation_error,
545 ..
546 } => {
547 format!(
548 "Plugin \"{}\" has invalid LSP server config for \"{}\": {}",
549 plugin, server_name, validation_error
550 )
551 }
552 PluginError::LspServerStartFailed {
553 plugin,
554 server_name,
555 reason,
556 ..
557 } => {
558 format!(
559 "Plugin \"{}\" failed to start LSP server \"{}\": {}",
560 plugin, server_name, reason
561 )
562 }
563 PluginError::LspServerCrashed {
564 plugin,
565 server_name,
566 exit_code,
567 signal,
568 ..
569 } => {
570 if let Some(s) = signal {
571 format!(
572 "Plugin \"{}\" LSP server \"{}\" crashed with signal {}",
573 plugin, server_name, s
574 )
575 } else {
576 format!(
577 "Plugin \"{}\" LSP server \"{}\" crashed with exit code {}",
578 plugin,
579 server_name,
580 exit_code
581 .map(|c| c.to_string())
582 .unwrap_or_else(|| "unknown".to_string())
583 )
584 }
585 }
586 PluginError::LspRequestTimeout {
587 plugin,
588 server_name,
589 method,
590 timeout_ms,
591 ..
592 } => {
593 format!(
594 "Plugin \"{}\" LSP server \"{}\" timed out on {} request after {}ms",
595 plugin, server_name, method, timeout_ms
596 )
597 }
598 PluginError::LspRequestFailed {
599 plugin,
600 server_name,
601 method,
602 error,
603 ..
604 } => {
605 format!(
606 "Plugin \"{}\" LSP server \"{}\" {} request failed: {}",
607 plugin, server_name, method, error
608 )
609 }
610 PluginError::MarketplaceBlockedByPolicy {
611 marketplace,
612 blocked_by_blocklist,
613 ..
614 } => {
615 if blocked_by_blocklist.unwrap_or(false) {
616 format!(
617 "Marketplace '{}' is blocked by enterprise policy",
618 marketplace
619 )
620 } else {
621 format!(
622 "Marketplace '{}' is not in the allowed marketplace list",
623 marketplace
624 )
625 }
626 }
627 PluginError::DependencyUnsatisfied {
628 dependency, reason, ..
629 } => {
630 let hint = if reason == "not-enabled" {
631 "disabled — enable it or remove the dependency"
632 } else {
633 "not found in any configured marketplace"
634 };
635 format!("Dependency \"{}\" is {}", dependency, hint)
636 }
637 PluginError::PluginCacheMiss {
638 plugin,
639 install_path,
640 ..
641 } => {
642 format!(
643 "Plugin \"{}\" not cached at {} — run /plugins to refresh",
644 plugin, install_path
645 )
646 }
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 #[test]
655 fn test_plugin_author_serialization() {
656 let author = PluginAuthor {
657 name: "Test Author".to_string(),
658 email: Some("test@example.com".to_string()),
659 url: Some("https://example.com".to_string()),
660 };
661 let json = serde_json::to_string(&author).unwrap();
662 assert!(json.contains("test@example.com"));
663 }
664
665 #[test]
666 fn test_plugin_manifest_serialization() {
667 let manifest = PluginManifest {
668 name: "test-plugin".to_string(),
669 version: Some("1.0.0".to_string()),
670 description: Some("A test plugin".to_string()),
671 author: None,
672 homepage: None,
673 repository: None,
674 license: None,
675 keywords: None,
676 dependencies: None,
677 commands: None,
678 agents: None,
679 skills: None,
680 hooks: None,
681 output_styles: None,
682 channels: None,
683 mcp_servers: None,
684 lsp_servers: None,
685 settings: None,
686 user_config: None,
687 };
688 let json = serde_json::to_string(&manifest).unwrap();
689 assert!(json.contains("test-plugin"));
690 assert!(json.contains("1.0.0"));
691 }
692
693 #[test]
694 fn test_plugin_component_display() {
695 assert_eq!(PluginComponent::Commands.to_string(), "commands");
696 assert_eq!(PluginComponent::Agents.to_string(), "agents");
697 assert_eq!(PluginComponent::Skills.to_string(), "skills");
698 assert_eq!(PluginComponent::Hooks.to_string(), "hooks");
699 assert_eq!(PluginComponent::OutputStyles.to_string(), "output-styles");
700 }
701
702 #[test]
703 fn test_plugin_error_generic() {
704 let error = PluginError::GenericError {
705 source: "test".to_string(),
706 plugin: Some("my-plugin".to_string()),
707 error: "Something went wrong".to_string(),
708 };
709 let message = get_plugin_error_message(&error);
710 assert_eq!(message, "Something went wrong");
711 }
712
713 #[test]
714 fn test_plugin_error_path_not_found() {
715 let error = PluginError::PathNotFound {
716 source: "test".to_string(),
717 plugin: Some("my-plugin".to_string()),
718 path: "./commands/test.md".to_string(),
719 component: PluginComponent::Commands,
720 };
721 let message = get_plugin_error_message(&error);
722 assert!(message.contains("Path not found"));
723 assert!(message.contains("commands"));
724 }
725
726 #[test]
727 fn test_plugin_error_network() {
728 let error = PluginError::NetworkError {
729 source: "test".to_string(),
730 plugin: None,
731 url: "https://example.com".to_string(),
732 details: Some("Connection refused".to_string()),
733 };
734 let message = get_plugin_error_message(&error);
735 assert!(message.contains("Network error"));
736 assert!(message.contains("Connection refused"));
737 }
738
739 #[test]
740 fn test_plugin_load_result() {
741 let result = PluginLoadResult {
742 enabled: vec![],
743 disabled: vec![],
744 errors: vec![],
745 };
746 let json = serde_json::to_string(&result).unwrap();
747 assert!(json.contains("enabled"));
748 assert!(json.contains("disabled"));
749 assert!(json.contains("errors"));
750 }
751}