oxi/internal_urls/
skill_handler.rs1use std::sync::Arc;
4
5use async_trait::async_trait;
6use oxi_sdk::SdkError;
7use oxi_sdk::ports::{ProtocolHandler, ResolveContext, ResolvedUrl};
8
9pub struct SkillProtocolHandler {
11 loader: Arc<dyn oxi_sdk::ports::SkillLoader>,
12}
13
14impl SkillProtocolHandler {
15 pub fn new(loader: Arc<dyn oxi_sdk::ports::SkillLoader>) -> Self {
17 Self { loader }
18 }
19}
20
21#[async_trait]
22impl ProtocolHandler for SkillProtocolHandler {
23 fn scheme(&self) -> &str {
24 "skill"
25 }
26 fn immutable(&self) -> bool {
27 true
28 }
29
30 async fn resolve(
31 &self,
32 url: &str,
33 _selector: Option<&str>,
34 _ctx: &ResolveContext,
35 ) -> Result<ResolvedUrl, SdkError> {
36 let suffix = url.strip_prefix("skill://").unwrap_or(url);
37 let parts: Vec<&str> = suffix.splitn(2, '/').collect();
38 let skill_name = parts[0];
39 if skill_name.is_empty() {
40 return Err(SdkError::ExecutionFailed {
41 reason: "skill:// URL requires a skill name".into(),
42 });
43 }
44
45 let skills = self.loader.list().await?;
46 let skill = skills
47 .iter()
48 .find(|s| s.name == skill_name)
49 .ok_or_else(|| {
50 let available: Vec<String> = skills.iter().map(|s| s.name.clone()).collect();
51 SdkError::ExecutionFailed {
52 reason: format!(
53 "Unknown skill: '{skill_name}'. Available: {}",
54 available.join(", ")
55 ),
56 }
57 })?;
58
59 let target_file = parts.get(1).copied().unwrap_or("SKILL.md");
60 if target_file.starts_with('/') || target_file.contains("..") {
61 return Err(SdkError::ExecutionFailed {
62 reason: "Path traversal is not allowed in skill:// URLs".into(),
63 });
64 }
65
66 let skill_dir = skill.path.parent().unwrap_or(std::path::Path::new(""));
67 let file_path = skill_dir.join(target_file);
68
69 let content =
70 tokio::fs::read_to_string(&file_path)
71 .await
72 .map_err(|e| SdkError::ExecutionFailed {
73 reason: format!("Failed to read skill file '{}': {e}", file_path.display()),
74 })?;
75
76 Ok(ResolvedUrl {
77 url: format!("skill://{suffix}"),
78 content,
79 content_type: if target_file.ends_with(".md") {
80 "text/markdown".into()
81 } else {
82 "text/plain".into()
83 },
84 size: None,
85 source_path: Some(file_path.to_string_lossy().into_owned()),
86 notes: vec![],
87 immutable: true,
88 })
89 }
90}