Skip to main content

ai_agent/plugin/
types.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/utils/filePersistence/types.ts
2//! Plugin types - ported from ~/claudecode/openclaudecode/src/types/plugin.ts
3//!
4//! This module provides the core plugin types for the Rust SDK.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Plugin author information
10#[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/// Command metadata when using object-mapping format
21#[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/// Command availability - determines which auth/provider environments can use the command
39#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum CommandAvailability {
42    /// claude.ai OAuth subscriber (Pro/Max/Team/Enterprise via claude.ai)
43    ClaudeAi,
44    /// Console API key user (direct api.anthropic.com)
45    Console,
46}
47
48/// How to display command result
49#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum CommandResultDisplay {
52    /// Skip displaying result
53    Skip,
54    /// Display as system message
55    System,
56    /// Display as user message (default)
57    User,
58}
59
60/// Command result types
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(tag = "type", rename_all = "snake_case")]
63pub enum CommandResult {
64    /// Text result
65    Text { value: String },
66    /// Skip messages
67    Skip,
68    /// Compact result
69    Compact {
70        #[serde(skip_serializing_if = "Option::is_none")]
71        display_text: Option<String>,
72    },
73}
74
75/// Command source - where the command was loaded from
76#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum CommandSource {
79    /// Builtin command
80    Builtin,
81    /// Loaded from skills directory
82    Skills,
83    /// Loaded from plugin
84    Plugin,
85    /// Managed/remote command
86    Managed,
87    /// Bundled command
88    Bundled,
89    /// MCP command
90    Mcp,
91}
92
93/// Plugin manifest (plugin.json structure)
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct PluginManifest {
97    /// Unique identifier for the plugin (kebab-case recommended)
98    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    // Additional component paths
116    #[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/// Plugin repository configuration
139#[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/// Plugin configuration (repositories)
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153pub struct PluginConfig {
154    pub repositories: HashMap<String, PluginRepository>,
155}
156
157/// Component types that a plugin can provide
158#[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/// Loaded plugin with all its metadata and paths
181#[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    /// Repository identifier, usually same as source
189    pub repository: String,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub enabled: Option<bool>,
192    /// true for built-in plugins that ship with the CLI
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub is_builtin: Option<bool>,
195    /// Git commit SHA for version pinning (from marketplace entry source)
196    #[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    /// Metadata for named commands from object-mapping format
203    #[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/// Discriminated union of plugin error types
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(tag = "type", rename_all = "kebab-case")]
230pub enum PluginError {
231    /// Path not found error
232    PathNotFound {
233        source: String,
234        #[serde(skip_serializing_if = "Option::is_none")]
235        plugin: Option<String>,
236        path: String,
237        component: PluginComponent,
238    },
239    /// Git authentication failed
240    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    /// Git operation timeout
248    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    /// Network error
256    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    /// Manifest parse error
265    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    /// Manifest validation error
273    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    /// Plugin not found in marketplace
281    PluginNotFound {
282        source: String,
283        plugin_id: String,
284        marketplace: String,
285    },
286    /// Marketplace not found
287    MarketplaceNotFound {
288        source: String,
289        marketplace: String,
290        available_marketplaces: Vec<String>,
291    },
292    /// Marketplace load failed
293    MarketplaceLoadFailed {
294        source: String,
295        marketplace: String,
296        reason: String,
297    },
298    /// MCP config invalid
299    McpConfigInvalid {
300        source: String,
301        plugin: String,
302        server_name: String,
303        validation_error: String,
304    },
305    /// MCP server suppressed duplicate
306    McpServerSuppressedDuplicate {
307        source: String,
308        plugin: String,
309        server_name: String,
310        duplicate_of: String,
311    },
312    /// LSP config invalid
313    LspConfigInvalid {
314        source: String,
315        plugin: String,
316        server_name: String,
317        validation_error: String,
318    },
319    /// Hook load failed
320    HookLoadFailed {
321        source: String,
322        plugin: String,
323        hook_path: String,
324        reason: String,
325    },
326    /// Component load failed
327    ComponentLoadFailed {
328        source: String,
329        plugin: String,
330        component: PluginComponent,
331        path: String,
332        reason: String,
333    },
334    /// MCPB download failed
335    McpbDownloadFailed {
336        source: String,
337        plugin: String,
338        url: String,
339        reason: String,
340    },
341    /// MCPB extract failed
342    McpbExtractFailed {
343        source: String,
344        plugin: String,
345        mcpb_path: String,
346        reason: String,
347    },
348    /// MCPB invalid manifest
349    McpbInvalidManifest {
350        source: String,
351        plugin: String,
352        mcpb_path: String,
353        validation_error: String,
354    },
355    /// LSP server start failed
356    LspServerStartFailed {
357        source: String,
358        plugin: String,
359        server_name: String,
360        reason: String,
361    },
362    /// LSP server crashed
363    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    /// LSP request timeout
372    LspRequestTimeout {
373        source: String,
374        plugin: String,
375        server_name: String,
376        method: String,
377        timeout_ms: u64,
378    },
379    /// LSP request failed
380    LspRequestFailed {
381        source: String,
382        plugin: String,
383        server_name: String,
384        method: String,
385        error: String,
386    },
387    /// Marketplace blocked by policy
388    MarketplaceBlockedByPolicy {
389        source: String,
390        #[serde(skip_serializing_if = "Option::is_none")]
391        plugin: Option<String>,
392        marketplace: String,
393        /// true if blocked by blockedMarketplaces, false if not in strictKnownMarketplaces
394        #[serde(skip_serializing_if = "Option::is_none")]
395        blocked_by_blocklist: Option<bool>,
396        /// Formatted source strings (e.g., "github:owner/repo")
397        allowed_sources: Vec<String>,
398    },
399    /// Dependency unsatisfied
400    DependencyUnsatisfied {
401        source: String,
402        plugin: String,
403        dependency: String,
404        reason: String,
405    },
406    /// Plugin cache miss
407    PluginCacheMiss {
408        source: String,
409        plugin: String,
410        install_path: String,
411    },
412    /// Generic error
413    GenericError {
414        source: String,
415        #[serde(skip_serializing_if = "Option::is_none")]
416        plugin: Option<String>,
417        error: String,
418    },
419}
420
421/// Result of loading plugins
422#[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
430/// Get a display message from any PluginError
431pub 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}