1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6use tirea_state::{GSet, State};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct SkillMeta {
10 pub id: String,
11 pub name: String,
12 pub description: String,
13 pub allowed_tools: Vec<String>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct SkillWarning {
18 pub path: PathBuf,
19 pub reason: String,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum SkillResourceKind {
25 Reference,
26 Asset,
27}
28
29impl SkillResourceKind {
30 pub fn as_str(self) -> &'static str {
31 match self {
32 Self::Reference => "reference",
33 Self::Asset => "asset",
34 }
35 }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct LoadedReference {
40 pub skill: String,
41 pub path: String,
42 pub sha256: String,
43 pub truncated: bool,
44 pub content: String,
45 pub bytes: u64,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct ScriptResult {
50 pub skill: String,
51 pub script: String,
52 pub sha256: String,
53 pub truncated_stdout: bool,
54 pub truncated_stderr: bool,
55 pub exit_code: i32,
56 pub stdout: String,
57 pub stderr: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct LoadedAsset {
62 pub skill: String,
63 pub path: String,
64 pub sha256: String,
65 pub truncated: bool,
66 pub bytes: u64,
67 pub media_type: Option<String>,
68 pub encoding: String,
69 pub content: String,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
78#[tirea(path = "skills", action = "SkillStateAction", scope = "thread")]
79pub struct SkillState {
80 #[serde(default)]
82 #[tirea(lattice)]
83 pub active: GSet<String>,
84}
85
86#[derive(Serialize, Deserialize)]
88pub enum SkillStateAction {
89 Activate(String),
91}
92
93impl SkillState {
94 fn reduce(&mut self, action: SkillStateAction) {
95 match action {
96 SkillStateAction::Activate(id) => {
97 self.active.insert(id);
98 }
99 }
100 }
101}
102
103pub fn material_key(skill_id: &str, relative_path: &str) -> String {
105 format!("{skill_id}:{relative_path}")
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109#[serde(tag = "kind", content = "resource", rename_all = "snake_case")]
110pub enum SkillResource {
111 Reference(LoadedReference),
112 Asset(LoadedAsset),
113}
114
115#[derive(Debug, thiserror::Error)]
116pub enum SkillMaterializeError {
117 #[error("invalid relative path: {0}")]
118 InvalidPath(String),
119
120 #[error("path is outside skill root")]
121 PathEscapesRoot,
122
123 #[error("unsupported path (expected under {0})")]
124 UnsupportedPath(String),
125
126 #[error("io error: {0}")]
127 Io(String),
128
129 #[error("script runtime not supported for: {0}")]
130 UnsupportedRuntime(String),
131
132 #[error("script timed out after {0}s")]
133 Timeout(u64),
134
135 #[error("invalid script arguments: {0}")]
136 InvalidScriptArgs(String),
137}
138
139#[derive(Debug, thiserror::Error)]
140pub enum SkillError {
141 #[error("unknown skill: {0}")]
142 UnknownSkill(String),
143
144 #[error("invalid SKILL.md: {0}")]
145 InvalidSkillMd(String),
146
147 #[error("materialize error: {0}")]
148 Materialize(#[from] SkillMaterializeError),
149
150 #[error("io error: {0}")]
151 Io(String),
152
153 #[error("duplicate skill id: {0}")]
154 DuplicateSkillId(String),
155
156 #[error("unsupported operation: {0}")]
157 Unsupported(String),
158}
159
160#[async_trait]
166pub trait Skill: Send + Sync + std::fmt::Debug {
167 fn meta(&self) -> &SkillMeta;
169
170 async fn read_instructions(&self) -> Result<String, SkillError>;
172
173 async fn load_resource(
175 &self,
176 kind: SkillResourceKind,
177 path: &str,
178 ) -> Result<SkillResource, SkillError>;
179
180 async fn run_script(&self, script: &str, args: &[String]) -> Result<ScriptResult, SkillError>;
182}
183
184pub fn collect_skills(
186 skills: Vec<Arc<dyn Skill>>,
187) -> Result<HashMap<String, Arc<dyn Skill>>, SkillError> {
188 let mut map: HashMap<String, Arc<dyn Skill>> = HashMap::new();
189 for skill in skills {
190 let id = skill.meta().id.clone();
191 if map.contains_key(&id) {
192 return Err(SkillError::DuplicateSkillId(id));
193 }
194 map.insert(id, skill);
195 }
196 Ok(map)
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn skill_error_preserves_materialize_variant() {
205 let err: SkillError = SkillMaterializeError::PathEscapesRoot.into();
206 assert!(matches!(
207 err,
208 SkillError::Materialize(SkillMaterializeError::PathEscapesRoot)
209 ));
210 }
211
212 #[test]
213 fn collect_skills_rejects_duplicates() {
214 #[derive(Debug)]
215 struct MockSkill(SkillMeta);
216
217 #[async_trait]
218 impl Skill for MockSkill {
219 fn meta(&self) -> &SkillMeta {
220 &self.0
221 }
222 async fn read_instructions(&self) -> Result<String, SkillError> {
223 Ok(String::new())
224 }
225 async fn load_resource(
226 &self,
227 _kind: SkillResourceKind,
228 _path: &str,
229 ) -> Result<SkillResource, SkillError> {
230 Err(SkillError::Unsupported("mock".into()))
231 }
232 async fn run_script(
233 &self,
234 _script: &str,
235 _args: &[String],
236 ) -> Result<ScriptResult, SkillError> {
237 Err(SkillError::Unsupported("mock".into()))
238 }
239 }
240
241 fn meta(id: &str) -> SkillMeta {
242 SkillMeta {
243 id: id.to_string(),
244 name: id.to_string(),
245 description: format!("{id} skill"),
246 allowed_tools: Vec::new(),
247 }
248 }
249
250 let skills: Vec<Arc<dyn Skill>> = vec![
251 Arc::new(MockSkill(meta("a"))),
252 Arc::new(MockSkill(meta("a"))),
253 ];
254 let err = collect_skills(skills).unwrap_err();
255 assert!(matches!(err, SkillError::DuplicateSkillId(ref id) if id == "a"));
256 }
257
258 #[test]
259 fn collect_skills_succeeds_for_unique_ids() {
260 #[derive(Debug)]
261 struct MockSkill(SkillMeta);
262
263 #[async_trait]
264 impl Skill for MockSkill {
265 fn meta(&self) -> &SkillMeta {
266 &self.0
267 }
268 async fn read_instructions(&self) -> Result<String, SkillError> {
269 Ok(String::new())
270 }
271 async fn load_resource(
272 &self,
273 _kind: SkillResourceKind,
274 _path: &str,
275 ) -> Result<SkillResource, SkillError> {
276 Err(SkillError::Unsupported("mock".into()))
277 }
278 async fn run_script(
279 &self,
280 _script: &str,
281 _args: &[String],
282 ) -> Result<ScriptResult, SkillError> {
283 Err(SkillError::Unsupported("mock".into()))
284 }
285 }
286
287 fn meta(id: &str) -> SkillMeta {
288 SkillMeta {
289 id: id.to_string(),
290 name: id.to_string(),
291 description: format!("{id} skill"),
292 allowed_tools: Vec::new(),
293 }
294 }
295
296 let skills: Vec<Arc<dyn Skill>> = vec![
297 Arc::new(MockSkill(meta("alpha"))),
298 Arc::new(MockSkill(meta("beta"))),
299 ];
300 let map = collect_skills(skills).unwrap();
301 assert_eq!(map.len(), 2);
302 assert!(map.contains_key("alpha"));
303 assert!(map.contains_key("beta"));
304 }
305}