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}