Skip to main content

runx_runtime/
export.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use runx_parser::{
6    CatalogVisibility, SkillInput, SkillRunnerDefinition, SkillRunnerManifest, ValidatedSkill,
7    parse_runner_manifest_yaml, parse_skill_markdown, validate_runner_manifest, validate_skill,
8};
9
10mod resolve;
11
12use resolve::resolve_skill_ref;
13
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct RunxExportSkill {
16    pub name: String,
17    pub description: String,
18    pub inputs: BTreeMap<String, RunxExportSkillInput>,
19    pub abs_dir: PathBuf,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct RunxExportSkillInput {
24    pub required: bool,
25    pub description: Option<String>,
26}
27
28#[derive(Clone, Debug)]
29pub struct RunxExportLoadOptions<'a> {
30    pub root: &'a Path,
31    pub refs: &'a [String],
32    pub official_roots: Vec<PathBuf>,
33}
34
35#[derive(Debug)]
36pub enum RunxExportLoadError {
37    InvalidArgs(String),
38    Io {
39        context: String,
40        source: std::io::Error,
41    },
42    Parse(String),
43}
44
45impl std::fmt::Display for RunxExportLoadError {
46    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::InvalidArgs(message) | Self::Parse(message) => formatter.write_str(message),
49            Self::Io { context, source } => write!(formatter, "{context}: {source}"),
50        }
51    }
52}
53
54impl std::error::Error for RunxExportLoadError {}
55
56pub fn load_export_skills(
57    root: &Path,
58    refs: &[String],
59) -> Result<Vec<RunxExportSkill>, RunxExportLoadError> {
60    load_export_skills_with_options(RunxExportLoadOptions {
61        root,
62        refs,
63        official_roots: Vec::new(),
64    })
65}
66
67pub fn load_export_skills_with_options(
68    options: RunxExportLoadOptions<'_>,
69) -> Result<Vec<RunxExportSkill>, RunxExportLoadError> {
70    let explicit = !options.refs.is_empty();
71    let paths = if explicit {
72        options
73            .refs
74            .iter()
75            .map(|reference| resolve_skill_ref(options.root, reference, &options.official_roots))
76            .collect::<Result<Vec<_>, _>>()?
77    } else {
78        discover_skill_paths(options.root)?
79    };
80
81    let mut skills = Vec::new();
82    for skill_dir in paths {
83        let manifest = read_optional_runner_manifest(&skill_dir)?;
84        if !explicit && manifest_visibility(&manifest) == Some(CatalogVisibility::Internal) {
85            continue;
86        }
87        let skill = read_validated_skill(&skill_dir)?;
88        let inputs = export_skill_inputs(&skill, manifest.as_ref());
89        validate_export_skill_name(&skill.name)?;
90        validate_export_skill_inputs(&inputs)?;
91        skills.push(RunxExportSkill {
92            name: skill.name,
93            description: skill
94                .description
95                .unwrap_or_else(|| "Run this skill through runx governance.".to_owned()),
96            inputs: inputs
97                .into_iter()
98                .map(|(name, input)| {
99                    (
100                        name,
101                        RunxExportSkillInput {
102                            required: input.required,
103                            description: input.description,
104                        },
105                    )
106                })
107                .collect(),
108            abs_dir: skill_dir,
109        });
110    }
111    skills.sort_by(|left, right| left.name.cmp(&right.name));
112    Ok(skills)
113}
114
115fn export_skill_inputs(
116    skill: &ValidatedSkill,
117    manifest: Option<&SkillRunnerManifest>,
118) -> BTreeMap<String, SkillInput> {
119    if !skill.inputs.is_empty() {
120        return skill.inputs.clone();
121    }
122    default_runner(manifest)
123        .map(|runner| runner.inputs.clone())
124        .unwrap_or_default()
125}
126
127fn default_runner(manifest: Option<&SkillRunnerManifest>) -> Option<&SkillRunnerDefinition> {
128    let manifest = manifest?;
129    manifest
130        .runners
131        .values()
132        .find(|runner| runner.default)
133        .or_else(|| {
134            (manifest.runners.len() == 1)
135                .then(|| manifest.runners.values().next())
136                .flatten()
137        })
138}
139
140fn validate_export_skill_name(name: &str) -> Result<(), RunxExportLoadError> {
141    if name == "." || name == ".." || name.contains('/') || name.contains('\\') {
142        return Err(RunxExportLoadError::InvalidArgs(format!(
143            "skill name {name:?} cannot be exported because it is not a safe path segment"
144        )));
145    }
146    Ok(())
147}
148
149fn validate_export_skill_inputs(
150    inputs: &BTreeMap<String, runx_parser::SkillInput>,
151) -> Result<(), RunxExportLoadError> {
152    for name in inputs.keys() {
153        if !is_export_input_name(name) || is_reserved_skill_flag(name) {
154            return Err(RunxExportLoadError::InvalidArgs(format!(
155                "skill input {name:?} cannot be exported because it is not a safe runx skill flag"
156            )));
157        }
158    }
159    Ok(())
160}
161
162fn is_export_input_name(name: &str) -> bool {
163    let mut chars = name.chars();
164    let Some(first) = chars.next() else {
165        return false;
166    };
167    if !(first.is_ascii_alphabetic() || first == '_') {
168        return false;
169    }
170    chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
171}
172
173fn is_reserved_skill_flag(name: &str) -> bool {
174    matches!(
175        name,
176        "answers"
177            | "credential"
178            | "json"
179            | "non_interactive"
180            | "receipt_dir"
181            | "run_id"
182            | "secret_env"
183    )
184}
185
186fn discover_skill_paths(root: &Path) -> Result<Vec<PathBuf>, RunxExportLoadError> {
187    let mut paths = Vec::new();
188    if root.join("SKILL.md").exists() {
189        paths.push(canonicalize(root, "canonicalizing root skill")?);
190    }
191    let skills_root = root.join("skills");
192    let entries = match fs::read_dir(&skills_root) {
193        Ok(entries) => entries,
194        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(paths),
195        Err(source) => {
196            return Err(RunxExportLoadError::Io {
197                context: format!("reading {}", display_path(&skills_root)),
198                source,
199            });
200        }
201    };
202    let mut candidates = entries
203        .map(|entry| {
204            entry
205                .map(|entry| entry.path())
206                .map_err(|source| RunxExportLoadError::Io {
207                    context: format!("reading {}", display_path(&skills_root)),
208                    source,
209                })
210        })
211        .collect::<Result<Vec<_>, _>>()?;
212    candidates.sort();
213    for candidate in candidates {
214        if candidate.join("SKILL.md").exists() {
215            paths.push(canonicalize(&candidate, "canonicalizing skill directory")?);
216        }
217    }
218    Ok(paths)
219}
220
221fn read_validated_skill(
222    skill_dir: &Path,
223) -> Result<runx_parser::ValidatedSkill, RunxExportLoadError> {
224    let path = skill_dir.join("SKILL.md");
225    let source = read_to_string(&path)?;
226    let raw = parse_skill_markdown(&source).map_err(|error| {
227        RunxExportLoadError::Parse(format!("parsing {}: {error}", display_path(&path)))
228    })?;
229    validate_skill(raw).map_err(|error| {
230        RunxExportLoadError::Parse(format!("validating {}: {error}", display_path(&path)))
231    })
232}
233
234fn read_optional_runner_manifest(
235    skill_dir: &Path,
236) -> Result<Option<runx_parser::SkillRunnerManifest>, RunxExportLoadError> {
237    let path = skill_dir.join("X.yaml");
238    if !path.exists() {
239        return Ok(None);
240    }
241    let source = read_to_string(&path)?;
242    let raw = parse_runner_manifest_yaml(&source).map_err(|error| {
243        RunxExportLoadError::Parse(format!("parsing {}: {error}", display_path(&path)))
244    })?;
245    validate_runner_manifest(raw).map(Some).map_err(|error| {
246        RunxExportLoadError::Parse(format!("validating {}: {error}", display_path(&path)))
247    })
248}
249
250fn manifest_visibility(
251    manifest: &Option<runx_parser::SkillRunnerManifest>,
252) -> Option<CatalogVisibility> {
253    manifest
254        .as_ref()
255        .and_then(|manifest| manifest.catalog.as_ref())
256        .map(|catalog| catalog.visibility)
257}
258
259fn canonicalize(path: &Path, context: &str) -> Result<PathBuf, RunxExportLoadError> {
260    fs::canonicalize(path).map_err(|source| RunxExportLoadError::Io {
261        context: format!("{context} {}", display_path(path)),
262        source,
263    })
264}
265
266fn read_to_string(path: &Path) -> Result<String, RunxExportLoadError> {
267    fs::read_to_string(path).map_err(|source| RunxExportLoadError::Io {
268        context: format!("reading {}", display_path(path)),
269        source,
270    })
271}
272
273fn display_path(path: &Path) -> String {
274    path.to_string_lossy().into_owned()
275}