Skip to main content

oxi/internal_urls/
skill_handler.rs

1//! `skill://` protocol handler — resolves skill names to SKILL.md content.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use oxi_sdk::SdkError;
7use oxi_sdk::ports::{ProtocolHandler, ResolveContext, ResolvedUrl};
8
9/// Protocol handler for `skill://` URLs backed by the SDK's `SkillLoader` port.
10pub struct SkillProtocolHandler {
11    loader: Arc<dyn oxi_sdk::ports::SkillLoader>,
12}
13
14impl SkillProtocolHandler {
15    /// Create a new handler backed by the given skill loader.
16    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}