Skip to main content

distri_types/
skill.rs

1use std::path::PathBuf;
2
3use anyhow::{Result, anyhow};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::agent::StandardDefinition;
8use crate::configuration::{
9    AgentConfig, DistriServerConfig, EntryPoints, PluginAgentDefinition, PluginArtifact,
10    PluginToolDefinition,
11};
12
13#[derive(Debug, Clone)]
14pub struct PluginMetadataRecord {
15    pub package_name: String,
16    pub version: Option<String>,
17    pub object_prefix: String,
18    pub entrypoint: Option<String>,
19    pub artifact: PluginArtifact,
20    pub updated_at: chrono::DateTime<chrono::Utc>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Skill {
25    pub id: String,
26    pub name: String,
27    pub description: String,
28    pub agent_definition: StandardDefinition,
29    pub files: Vec<SkillFile>,
30    pub metadata: SkillMetadata,
31    pub created_at: DateTime<Utc>,
32    pub updated_at: DateTime<Utc>,
33}
34
35impl Skill {
36    pub fn new(
37        id: String,
38        name: String,
39        description: String,
40        agent_definition: StandardDefinition,
41        files: Vec<SkillFile>,
42        metadata: SkillMetadata,
43        created_at: DateTime<Utc>,
44        updated_at: DateTime<Utc>,
45    ) -> Self {
46        Self {
47            id,
48            name,
49            description,
50            agent_definition,
51            files,
52            metadata,
53            created_at,
54            updated_at,
55        }
56    }
57
58    pub fn to_plugin_record(
59        &self,
60        object_prefix: String,
61        entrypoint: Option<String>,
62    ) -> PluginMetadataRecord {
63        let script_path = self
64            .files
65            .iter()
66            .find(|file| matches!(file.kind, SkillFileKind::Script))
67            .map(|file| file.path.clone())
68            .unwrap_or_else(|| "scripts/main.ts".to_string());
69
70        let mut configuration = DistriServerConfig::new_minimal(self.id.clone());
71        configuration.description = if self.description.is_empty() {
72            None
73        } else {
74            Some(self.description.clone())
75        };
76        if !self.metadata.tags.is_empty() {
77            configuration.keywords = Some(self.metadata.tags.clone());
78        }
79        configuration.entrypoints = Some(EntryPoints {
80            path: script_path.clone(),
81        });
82        configuration.agents = Some(vec![self.agent_definition.name.clone()]);
83
84        let agent_definition = PluginAgentDefinition {
85            name: self.agent_definition.name.clone(),
86            package_name: self.id.clone(),
87            description: self.agent_definition.description.clone(),
88            file_path: PathBuf::from(script_path.clone()),
89            agent_config: AgentConfig::StandardAgent(self.agent_definition.clone()),
90        };
91
92        let mut tools = Vec::new();
93        if let Some(export) = self
94            .files
95            .iter()
96            .find(|file| matches!(file.kind, SkillFileKind::Script))
97            .and_then(|file| file.export.clone())
98        {
99            match export {
100                SkillExport::Tool { name, description } => {
101                    tools.push(PluginToolDefinition {
102                        name,
103                        package_name: self.id.clone(),
104                        description: description.unwrap_or_else(|| self.description.clone()),
105                        parameters: serde_json::Value::Null,
106                        auth: None,
107                    });
108                }
109            }
110        }
111
112        let artifact = PluginArtifact {
113            name: self.name.clone(),
114            path: PathBuf::from(&object_prefix),
115            configuration,
116            tools,
117            agents: vec![agent_definition],
118        };
119
120        PluginMetadataRecord {
121            package_name: self.id.clone(),
122            version: Some(self.metadata.version.clone()),
123            object_prefix,
124            entrypoint: entrypoint.or(Some(script_path)),
125            artifact,
126            updated_at: self.updated_at,
127        }
128    }
129
130    pub fn from_plugin_record(
131        record: &PluginMetadataRecord,
132        mut files: Vec<SkillFile>,
133    ) -> Result<Self> {
134        let configuration = &record.artifact.configuration;
135        let description = configuration.description.clone().unwrap_or_default();
136        let version = record
137            .version
138            .clone()
139            .unwrap_or_else(|| configuration.version.clone());
140        let tags = configuration.keywords.clone().unwrap_or_default();
141
142        let agent_definition = record
143            .artifact
144            .agents
145            .iter()
146            .find_map(|agent| match &agent.agent_config {
147                AgentConfig::StandardAgent(def) => Some(def.clone()),
148                _ => None,
149            })
150            .ok_or_else(|| anyhow!("Skill plugin does not contain a standard agent definition"))?;
151
152        let export = record.artifact.tools.first().map(|tool| SkillExport::Tool {
153                name: tool.name.clone(),
154                description: if tool.description.is_empty() {
155                    None
156                } else {
157                    Some(tool.description.clone())
158                },
159            });
160
161        if let Some(export_value) = export
162            && let Some(script_file) = files
163                .iter_mut()
164                .find(|file| matches!(file.kind, SkillFileKind::Script))
165            {
166                script_file.export = Some(export_value);
167            }
168
169        let skill = Skill {
170            id: record.package_name.clone(),
171            name: record.artifact.name.clone(),
172            description,
173            agent_definition,
174            files,
175            metadata: SkillMetadata { version, tags },
176            created_at: record.updated_at,
177            updated_at: record.updated_at,
178        };
179
180        Ok(skill)
181    }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct SkillMetadata {
186    #[serde(default = "default_skill_version")]
187    pub version: String,
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub tags: Vec<String>,
190}
191
192impl Default for SkillMetadata {
193    fn default() -> Self {
194        Self {
195            version: default_skill_version(),
196            tags: Vec::new(),
197        }
198    }
199}
200
201fn default_skill_version() -> String {
202    "0.1.0".to_string()
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct SkillFile {
207    pub path: String,
208    pub content: String,
209    pub kind: SkillFileKind,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub export: Option<SkillExport>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215#[serde(rename_all = "snake_case")]
216pub enum SkillFileKind {
217    Script,
218    Markdown,
219    Asset,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(tag = "type", rename_all = "snake_case")]
224pub enum SkillExport {
225    Tool {
226        name: String,
227        description: Option<String>,
228    },
229}
230
231pub fn slugify_id(name: &str) -> String {
232    let mut slug = name
233        .to_lowercase()
234        .chars()
235        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
236        .collect::<String>();
237    while slug.contains("__") {
238        slug = slug.replace("__", "_");
239    }
240    slug.trim_matches('_').to_string()
241}