Skip to main content

runx_cli/
skill.rs

1// rust-style-allow: large-file - skill command keeps parse, inspect, registry provenance, and execution wiring together until the native skill UX settles.
2use 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    /// One-shot, per-run local credential descriptor supplied via
35    /// `--credential` and `--secret-env`. The secret is read from the named
36    /// process environment variable so raw secret material never appears on
37    /// argv. Runner-specific execution validates whether that delivery channel
38    /// is supported before any child process starts.
39    pub local_credential: Option<LocalCredentialDescriptor>,
40}
41
42#[derive(Debug, PartialEq)]
43pub enum SkillAction {
44    Inspect,
45    Run,
46}
47
48// rust-style-allow: long-function - the top-level command path owns resolve/inspect/run/failure presentation in one explicit dispatch.
49pub 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
132// rust-style-allow: long-function - inspection assembles one public JSON contract from SKILL.md, X.yaml, fixtures, and selected runner metadata.
133fn 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
216// rust-style-allow: long-function - text rendering mirrors the inspect JSON shape and is kept adjacent to avoid presentation drift.
217fn 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}