Skip to main content

telos_agent/integrations/plugin/
mod.rs

1//! Plugin system — marketplace-based extensibility for the agent runtime.
2//!
3//! A plugin is a directory containing a `plugin.json` manifest that declares
4//! which components it provides: tools, policies, skills, MCP servers, agents,
5//! prompt sections, and output styles.
6//!
7//! Plugins are installed from marketplaces — curated collections fetched from
8//! GitHub, git URLs, npm, pip, or local directories.
9
10pub mod errors;
11pub mod manifest;
12pub mod marketplace;
13pub mod policy_loader;
14pub mod registry;
15pub mod sources;
16pub mod tool_loader;
17
18use serde::{Deserialize, Serialize};
19use std::fmt;
20
21pub use errors::{DependencyReason, PluginError};
22pub use manifest::{
23    CommandPolicyDef, ConfigOptionType, DependencyRef, LspServerEntry, LspServersConfig,
24    MarketplaceEntry, McpServerEntry, McpServersConfig, PartialPluginManifest, PluginAuthor,
25    PluginManifest, PluginSource, PoliciesConfig, SessionPolicyDef, ToolPolicyDef,
26    UserConfigOption,
27};
28pub use marketplace::{Marketplace, MarketplaceRegistry};
29pub use registry::{LoadedPlugin, PluginEntry, PluginRegistry, PluginStatus};
30pub use sources::MarketplaceSource;
31pub use tool_loader::{CommandTool, ToolPermission, ToolSpec, load_tool_spec};
32
33/// Universal plugin identifier: `name@marketplace`.
34///
35/// Both parts use kebab-case alphanumeric with dots, hyphens, and underscores.
36///
37/// # Examples
38/// - `code-formatter@telos-official`
39/// - `my-plugin@builtin`
40#[derive(Debug, Clone, PartialEq, Eq, Hash)]
41pub struct PluginId {
42    pub name: String,
43    pub marketplace: String,
44}
45
46/// Sentinel marketplace name for built-in plugins that ship with the binary.
47pub const BUILTIN_MARKETPLACE: &str = "builtin";
48
49/// A prompt section backed by a static template string from a plugin.
50pub struct PluginPromptSection {
51    pub name: String,
52    pub template: String,
53}
54
55#[async_trait::async_trait]
56impl crate::agent::prompt::PromptSection for PluginPromptSection {
57    fn name(&self) -> &str {
58        &self.name
59    }
60    fn stability(&self) -> crate::agent::prompt::PromptStability {
61        crate::agent::prompt::PromptStability::Static
62    }
63    async fn render(&self, _ctx: &()) -> String {
64        self.template.clone()
65    }
66}
67
68impl PluginId {
69    /// Parse a "name@marketplace" string into a PluginId.
70    ///
71    /// Returns `None` if the string doesn't contain exactly one `@`.
72    pub fn parse(raw: &str) -> Option<Self> {
73        let (name, marketplace) = raw.split_once('@')?;
74        if name.is_empty() || marketplace.is_empty() {
75            return None;
76        }
77        // Reject multiple @ signs
78        if marketplace.contains('@') {
79            return None;
80        }
81        Some(Self { name: name.to_string(), marketplace: marketplace.to_string() })
82    }
83}
84
85impl fmt::Display for PluginId {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        write!(f, "{}@{}", self.name, self.marketplace)
88    }
89}
90
91impl Serialize for PluginId {
92    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
93        self.to_string().serialize(serializer)
94    }
95}
96
97impl<'de> Deserialize<'de> for PluginId {
98    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
99        let s = String::deserialize(deserializer)?;
100        PluginId::parse(&s).ok_or_else(|| {
101            serde::de::Error::custom(format!(
102                "invalid PluginId '{s}': expected 'name@marketplace' format"
103            ))
104        })
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn parse_valid_plugin_id() {
114        let id = PluginId::parse("foo@bar").unwrap();
115        assert_eq!(id.name, "foo");
116        assert_eq!(id.marketplace, "bar");
117    }
118
119    #[test]
120    fn parse_with_dots_and_hyphens() {
121        let id = PluginId::parse("code-formatter@telos-official").unwrap();
122        assert_eq!(id.name, "code-formatter");
123        assert_eq!(id.marketplace, "telos-official");
124    }
125
126    #[test]
127    fn parse_missing_at_returns_none() {
128        assert!(PluginId::parse("foobar").is_none());
129    }
130
131    #[test]
132    fn parse_empty_name_returns_none() {
133        assert!(PluginId::parse("@bar").is_none());
134    }
135
136    #[test]
137    fn parse_empty_marketplace_returns_none() {
138        assert!(PluginId::parse("foo@").is_none());
139    }
140
141    #[test]
142    fn parse_multiple_at_returns_none() {
143        assert!(PluginId::parse("foo@bar@baz").is_none());
144    }
145
146    #[test]
147    fn display_roundtrips() {
148        let id = PluginId { name: "foo".into(), marketplace: "bar".into() };
149        assert_eq!(id.to_string(), "foo@bar");
150    }
151
152    #[test]
153    fn serde_roundtrip() {
154        let id = PluginId { name: "foo".into(), marketplace: "bar".into() };
155        let json = serde_json::to_string(&id).unwrap();
156        assert_eq!(json, r#""foo@bar""#);
157        let parsed: PluginId = serde_json::from_str(&json).unwrap();
158        assert_eq!(parsed, id);
159    }
160
161    #[test]
162    fn serde_invalid_rejected() {
163        let result: Result<PluginId, _> = serde_json::from_str(r#""no-at-sign""#);
164        assert!(result.is_err());
165    }
166}