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}