1use std::collections::BTreeMap;
3use std::env;
4use std::fs;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7use std::process::ExitCode;
8
9use runx_contracts::{JsonObject, JsonValue};
10use runx_runtime::SkillRunRequest;
11use runx_runtime::orchestrator::LocalCredentialDescriptor;
12
13mod inputs;
14mod output;
15mod parser;
16mod resolver;
17
18use output::{SkillOutputResume, skill_result_exit_code, write_skill_output};
19pub use parser::parse_skill_plan;
20use resolver::{RegistryTrustState, ResolvedSkillRef, resolve_skill_ref_details};
21
22#[derive(Debug, PartialEq)]
23pub struct SkillPlan {
24 pub action: SkillAction,
25 pub skill_path: PathBuf,
26 pub runner: Option<String>,
27 pub receipt_dir: Option<PathBuf>,
28 pub run_id: Option<String>,
29 pub answers: Option<PathBuf>,
30 pub registry: Option<String>,
31 pub expected_digest: Option<String>,
32 pub json: bool,
33 pub inputs: BTreeMap<String, JsonValue>,
34 pub local_credential: Option<LocalCredentialDescriptor>,
40}
41
42#[derive(Debug, PartialEq)]
43pub enum SkillAction {
44 Inspect,
45 Run,
46}
47
48pub fn run_native_skill(plan: SkillPlan) -> ExitCode {
50 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
51 let env = env::vars().collect();
52 let resume_skill_ref = plan.skill_path.to_string_lossy().into_owned();
53 let resolved = match resolve_skill_ref_details(
54 &plan.skill_path,
55 &cwd,
56 resolver::SkillResolverOptions {
57 env: &env,
58 registry: plan.registry.as_deref(),
59 expected_digest: plan.expected_digest.as_deref(),
60 },
61 ) {
62 Ok(skill_path) => skill_path,
63 Err(error) => {
64 return write_skill_failure(&error.to_string(), plan.json, "skill_error", 1, None);
65 }
66 };
67 let skill_path = resolved.runnable_path.clone();
68 if plan.action == SkillAction::Inspect {
69 return write_skill_inspection(
70 &skill_path,
71 plan.runner.as_deref(),
72 plan.json,
73 registry_provenance(&resolved),
74 );
75 }
76 let resume = SkillOutputResume {
77 skill_ref: Some(&resume_skill_ref),
78 selected_runner: plan.runner.as_deref(),
79 receipt_dir: plan.receipt_dir.as_deref(),
80 answers_path: plan.answers.as_deref(),
81 };
82 let request = SkillRunRequest {
83 skill_path,
84 receipt_dir: plan.receipt_dir.clone(),
85 run_id: plan.run_id.clone(),
86 answers_path: plan.answers.clone(),
87 inputs: plan.inputs,
88 env,
89 cwd,
90 local_credential: plan.local_credential,
91 };
92 let orchestrator = crate::runtime::local_orchestrator();
93 let result = match plan.runner.as_deref() {
94 Some(runner) => orchestrator.run_skill_with_runner(&request, runner),
95 None => orchestrator.run_skill(&request),
96 };
97 match result {
98 Ok(mut result) => {
99 attach_registry_provenance(&mut result.output, &resolved);
100 let exit_code = skill_result_exit_code(&result.output);
101 write_skill_output(&result.output, plan.json, exit_code, resume)
102 }
103 Err(error) => write_skill_failure(
104 &error.to_string(),
105 plan.json,
106 "skill_error",
107 1,
108 registry_provenance(&resolved),
109 ),
110 }
111}
112
113fn write_skill_inspection(
114 skill_path: &Path,
115 runner: Option<&str>,
116 json: bool,
117 provenance: Option<JsonObject>,
118) -> ExitCode {
119 match inspect_skill(skill_path, runner, provenance) {
120 Ok(value) if json => crate::cli_io::write_stdout_code(
121 &format!(
122 "{}\n",
123 serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_owned())
124 ),
125 0,
126 ),
127 Ok(value) => write_inspection_text(&value),
128 Err(message) => write_skill_failure(&message, json, "skill_error", 1, None),
129 }
130}
131
132fn inspect_skill(
134 skill_path: &Path,
135 selected_runner: Option<&str>,
136 provenance: Option<JsonObject>,
137) -> Result<JsonValue, String> {
138 let skill_dir = skill_directory(skill_path);
139 let skill_md = fs::read_to_string(skill_dir.join("SKILL.md")).map_err(|error| {
140 format!(
141 "could not read skill markdown {}: {error}",
142 skill_dir.join("SKILL.md").display()
143 )
144 })?;
145 let frontmatter = parse_skill_frontmatter(&skill_md)?;
146 let x_yaml_path = skill_dir.join("X.yaml");
147 let profile = match fs::read_to_string(&x_yaml_path) {
148 Ok(contents) => parse_yaml_object(&contents, &x_yaml_path)?,
149 Err(error) if error.kind() == std::io::ErrorKind::NotFound => JsonObject::new(),
150 Err(error) => {
151 return Err(format!("could not read {}: {error}", x_yaml_path.display()));
152 }
153 };
154 let runners = profile
155 .get("runners")
156 .and_then(JsonValue::as_object)
157 .cloned()
158 .unwrap_or_default();
159 let mut output = JsonObject::new();
160 output.insert(
161 "schema".to_owned(),
162 JsonValue::String("runx.skill.inspect.v1".to_owned()),
163 );
164 output.insert("status".to_owned(), JsonValue::String("ok".to_owned()));
165 insert_frontmatter_string(&mut output, &frontmatter, "name", "name");
166 insert_frontmatter_string(&mut output, &frontmatter, "description", "description");
167 if let Some(version) = profile.get("version").and_then(JsonValue::as_str) {
168 output.insert("version".to_owned(), JsonValue::String(version.to_owned()));
169 }
170 if let Some(provenance) = provenance {
171 output.insert(
172 "registry_provenance".to_owned(),
173 JsonValue::Object(provenance),
174 );
175 }
176 output.insert(
177 "skill_path".to_owned(),
178 JsonValue::String(skill_dir.to_string_lossy().into_owned()),
179 );
180 output.insert(
181 "runners".to_owned(),
182 JsonValue::Array(
183 runners
184 .keys()
185 .map(|runner| JsonValue::String(runner.clone()))
186 .collect(),
187 ),
188 );
189 if let Some(runner) = selected_runner {
190 let runner_def = runners
191 .get(runner)
192 .and_then(JsonValue::as_object)
193 .ok_or_else(|| format!("skill has no runner '{runner}'"))?;
194 output.insert("runner".to_owned(), inspect_runner(runner, runner_def));
195 output.insert(
196 "examples".to_owned(),
197 JsonValue::Array(fixture_examples(&skill_dir, runner)),
198 );
199 output.insert(
200 "resume".to_owned(),
201 JsonValue::Object(JsonObject::from([
202 (
203 "may_pause".to_owned(),
204 JsonValue::Bool(runner_may_pause(runner_def)),
205 ),
206 (
207 "command".to_owned(),
208 JsonValue::String("runx resume <run-id> answers.json".to_owned()),
209 ),
210 ])),
211 );
212 }
213 Ok(JsonValue::Object(output))
214}
215
216fn write_inspection_text(value: &JsonValue) -> ExitCode {
218 let Some(object) = value.as_object() else {
219 return crate::cli_io::write_stdout_code("{}\n", 0);
220 };
221 let mut out = String::new();
222 out.push_str(&format!(
223 "skill: {}\n",
224 object_string(object, "name").unwrap_or("<unnamed>")
225 ));
226 if let Some(description) = object_string(object, "description") {
227 out.push_str(&format!("description: {description}\n"));
228 }
229 if let Some(version) = object_string(object, "version") {
230 out.push_str(&format!("version: {version}\n"));
231 }
232 if let Some(runner) = object.get("runner").and_then(JsonValue::as_object) {
233 out.push_str(&format!(
234 "runner: {}\n",
235 object_string(runner, "name").unwrap_or("<unknown>")
236 ));
237 if let Some(kind) = object_string(runner, "type") {
238 out.push_str(&format!("type: {kind}\n"));
239 }
240 if let Some(inputs) = runner.get("inputs").and_then(JsonValue::as_array) {
241 if !inputs.is_empty() {
242 out.push_str("inputs:\n");
243 for input in inputs {
244 if let Some(input) = input.as_object() {
245 let name = object_string(input, "name").unwrap_or("<unknown>");
246 let kind = object_string(input, "type").unwrap_or("json");
247 let required = input
248 .get("required")
249 .and_then(JsonValue::as_bool)
250 .unwrap_or(false);
251 let marker = if required { "required" } else { "optional" };
252 out.push_str(&format!(" - {name}: {kind} ({marker})\n"));
253 }
254 }
255 }
256 }
257 if let Some(examples) = object.get("examples").and_then(JsonValue::as_array)
258 && !examples.is_empty()
259 {
260 out.push_str("examples:\n");
261 for example in examples {
262 if let Some(example) = example.as_str() {
263 out.push_str(&format!(" - {example}\n"));
264 }
265 }
266 }
267 if let Some(resume) = object.get("resume").and_then(JsonValue::as_object)
268 && resume
269 .get("may_pause")
270 .and_then(JsonValue::as_bool)
271 .unwrap_or(false)
272 {
273 out.push_str(&format!(
274 "resume: {}\n",
275 object_string(resume, "command").unwrap_or("runx resume <run-id> answers.json")
276 ));
277 }
278 out.push_str("run: runx skill <skill> [runner]\n");
279 } else if let Some(runners) = object.get("runners").and_then(JsonValue::as_array) {
280 out.push_str("runners:\n");
281 for runner in runners {
282 if let Some(runner) = runner.as_str() {
283 out.push_str(&format!(" - {runner}\n"));
284 }
285 }
286 out.push_str("next: runx skill <skill> <runner>\n");
287 }
288 crate::cli_io::write_stdout_code(&out, 0)
289}
290
291fn skill_directory(skill_path: &Path) -> PathBuf {
292 if skill_path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md") {
293 return skill_path.parent().unwrap_or(skill_path).to_path_buf();
294 }
295 skill_path.to_path_buf()
296}
297
298fn parse_skill_frontmatter(markdown: &str) -> Result<JsonObject, String> {
299 let Some(rest) = markdown.strip_prefix("---") else {
300 return Ok(JsonObject::new());
301 };
302 let Some((frontmatter, _body)) = rest.split_once("\n---") else {
303 return Ok(JsonObject::new());
304 };
305 serde_norway::from_str::<JsonValue>(frontmatter)
306 .map_err(|error| format!("skill frontmatter is invalid YAML: {error}"))
307 .map(|value| match value {
308 JsonValue::Object(object) => object,
309 _ => JsonObject::new(),
310 })
311}
312
313fn parse_yaml_object(contents: &str, path: &Path) -> Result<JsonObject, String> {
314 serde_norway::from_str::<JsonValue>(contents)
315 .map_err(|error| format!("{} is invalid YAML: {error}", path.display()))
316 .and_then(|value| match value {
317 JsonValue::Object(object) => Ok(object),
318 _ => Err(format!("{} must contain a YAML object", path.display())),
319 })
320}
321
322fn insert_frontmatter_string(
323 output: &mut JsonObject,
324 frontmatter: &JsonObject,
325 source_key: &str,
326 output_key: &str,
327) {
328 if let Some(value) = object_string(frontmatter, source_key) {
329 output.insert(output_key.to_owned(), JsonValue::String(value.to_owned()));
330 }
331}
332
333fn inspect_runner(name: &str, runner: &JsonObject) -> JsonValue {
334 let mut output = JsonObject::new();
335 output.insert("name".to_owned(), JsonValue::String(name.to_owned()));
336 if let Some(kind) = object_string(runner, "type") {
337 output.insert("type".to_owned(), JsonValue::String(kind.to_owned()));
338 }
339 let inputs = runner
340 .get("inputs")
341 .and_then(JsonValue::as_object)
342 .map(|inputs| {
343 inputs
344 .iter()
345 .map(|(name, input)| inspect_input(name, input))
346 .collect::<Vec<_>>()
347 })
348 .unwrap_or_default();
349 output.insert("inputs".to_owned(), JsonValue::Array(inputs));
350 JsonValue::Object(output)
351}
352
353fn inspect_input(name: &str, value: &JsonValue) -> JsonValue {
354 let mut output = JsonObject::new();
355 output.insert("name".to_owned(), JsonValue::String(name.to_owned()));
356 if let Some(input) = value.as_object() {
357 if let Some(kind) = object_string(input, "type") {
358 output.insert("type".to_owned(), JsonValue::String(kind.to_owned()));
359 }
360 output.insert(
361 "required".to_owned(),
362 JsonValue::Bool(
363 input
364 .get("required")
365 .and_then(JsonValue::as_bool)
366 .unwrap_or(false),
367 ),
368 );
369 if let Some(description) = object_string(input, "description") {
370 output.insert(
371 "description".to_owned(),
372 JsonValue::String(description.to_owned()),
373 );
374 }
375 }
376 JsonValue::Object(output)
377}
378
379fn object_string<'a>(object: &'a JsonObject, key: &str) -> Option<&'a str> {
380 object.get(key).and_then(JsonValue::as_str)
381}
382
383fn fixture_examples(skill_dir: &Path, runner: &str) -> Vec<JsonValue> {
384 let fixtures_dir = skill_dir.join("fixtures");
385 let Ok(entries) = fs::read_dir(fixtures_dir) else {
386 return Vec::new();
387 };
388 let mut fixtures = entries
389 .filter_map(Result::ok)
390 .filter_map(|entry| {
391 let path = entry.path();
392 let name = path.file_name()?.to_str()?.to_owned();
393 (name.ends_with(".yaml") && fixture_targets_runner(&path, runner)).then_some(name)
394 })
395 .map(JsonValue::String)
396 .collect::<Vec<_>>();
397 fixtures.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
398 fixtures
399}
400
401fn fixture_targets_runner(path: &Path, runner: &str) -> bool {
402 fs::read_to_string(path)
403 .ok()
404 .and_then(|contents| serde_norway::from_str::<JsonValue>(&contents).ok())
405 .and_then(|value| value.as_object().cloned())
406 .and_then(|object| {
407 object
408 .get("runner")
409 .and_then(JsonValue::as_str)
410 .map(str::to_owned)
411 })
412 .is_some_and(|fixture_runner| fixture_runner == runner)
413}
414
415fn runner_may_pause(runner: &JsonObject) -> bool {
416 matches!(
417 object_string(runner, "type"),
418 Some("agent") | Some("agent-task") | Some("graph")
419 )
420}
421
422fn attach_registry_provenance(output: &mut JsonValue, resolved: &ResolvedSkillRef) {
423 let Some(provenance) = registry_provenance(resolved) else {
424 return;
425 };
426 let JsonValue::Object(object) = output else {
427 return;
428 };
429 object.insert(
430 "registry_provenance".to_owned(),
431 JsonValue::Object(provenance),
432 );
433}
434
435fn registry_provenance(resolved: &ResolvedSkillRef) -> Option<JsonObject> {
436 let skill_id = resolved.skill_id.as_ref()?;
437 let mut provenance = JsonObject::new();
438 provenance.insert("skill_id".to_owned(), JsonValue::String(skill_id.clone()));
439 insert_optional(&mut provenance, "version", resolved.version.as_ref());
440 insert_optional(&mut provenance, "digest", resolved.digest.as_ref());
441 insert_optional(
442 &mut provenance,
443 "profile_digest",
444 resolved.profile_digest.as_ref(),
445 );
446 insert_optional(
447 &mut provenance,
448 "registry_source",
449 resolved.registry_source.as_ref(),
450 );
451 insert_optional(
452 &mut provenance,
453 "registry_source_fingerprint",
454 resolved.registry_source_fingerprint.as_ref(),
455 );
456 insert_optional(&mut provenance, "trust_tier", resolved.trust_tier.as_ref());
457 insert_optional(
458 &mut provenance,
459 "registry_key_id",
460 resolved.registry_key_id.as_ref(),
461 );
462 if matches!(
463 resolved.trust_state.as_ref(),
464 Some(RegistryTrustState::Trusted)
465 ) {
466 provenance.insert(
467 "trust_state".to_owned(),
468 JsonValue::String("trusted".to_owned()),
469 );
470 }
471 Some(provenance)
472}
473
474fn insert_optional(object: &mut JsonObject, key: &str, value: Option<&String>) {
475 if let Some(value) = value {
476 object.insert(key.to_owned(), JsonValue::String(value.clone()));
477 }
478}
479
480fn write_skill_failure(
481 message: &str,
482 json: bool,
483 code: &str,
484 exit_code: u8,
485 provenance: Option<JsonObject>,
486) -> ExitCode {
487 if json {
488 let output = skill_json_failure_output(message, code, provenance);
489 return crate::cli_io::write_stdout_code(&output, exit_code);
490 }
491 let _ignored = writeln!(io::stderr(), "runx: {message}");
492 ExitCode::from(exit_code)
493}
494
495fn skill_json_failure_output(message: &str, code: &str, provenance: Option<JsonObject>) -> String {
496 let mut error = JsonObject::new();
497 error.insert("message".to_owned(), JsonValue::String(message.to_owned()));
498 error.insert("code".to_owned(), JsonValue::String(code.to_owned()));
499 let mut output = JsonObject::new();
500 output.insert("status".to_owned(), JsonValue::String("failure".to_owned()));
501 output.insert("error".to_owned(), JsonValue::Object(error));
502 if let Some(provenance) = provenance {
503 output.insert(
504 "registry_provenance".to_owned(),
505 JsonValue::Object(provenance),
506 );
507 }
508 serde_json::to_string_pretty(&JsonValue::Object(output))
509 .map(|json| format!("{json}\n"))
510 .unwrap_or_else(|_| crate::router::json_failure_output(message, code))
511}