Skip to main content

runx_cli/skill/
parser.rs

1// rust-style-allow: large-file - skill CLI parsing keeps shared state and
2// option finalization in one module until the native parser surface stabilizes.
3use std::collections::BTreeMap;
4use std::env;
5use std::ffi::OsString;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use runx_contracts::JsonValue;
10use runx_runtime::orchestrator::LocalCredentialDescriptor;
11use runx_runtime::{resolve_path_from_user_input, resolve_runx_home_dir};
12use serde::Deserialize;
13
14use super::inputs::{parse_direct_input_arg, parse_input_arg, parse_json_input_arg};
15use super::{SkillAction, SkillPlan};
16
17pub fn parse_skill_plan(args: &[OsString]) -> Result<SkillPlan, String> {
18    let mut state = SkillParseState::default();
19    let mut index = 1;
20
21    while index < args.len() {
22        index = parse_skill_arg(args, index, &mut state)?;
23        index += 1;
24    }
25
26    let env = env::vars().collect();
27    let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
28    let local_credential = finalize_local_credential(&state, &env, &cwd)?;
29
30    let Some(skill_path) = state.skill_path.as_ref() else {
31        return Err("runx skill requires a skill package path".to_owned());
32    };
33    reject_resolver_flags_for_skill_management_action(skill_path, &state)?;
34    let skill_path = skill_path.clone();
35    let action = if state.inspect {
36        SkillAction::Inspect
37    } else {
38        SkillAction::Run
39    };
40
41    Ok(SkillPlan {
42        action,
43        skill_path,
44        runner: state.runner,
45        receipt_dir: state.receipt_dir,
46        run_id: state.run_id,
47        answers: state.answers,
48        registry: state.registry,
49        expected_digest: state.expected_digest,
50        json: state.json,
51        inputs: state.inputs,
52        local_credential,
53    })
54}
55
56#[derive(Default)]
57struct SkillParseState {
58    skill_path: Option<PathBuf>,
59    runner: Option<String>,
60    receipt_dir: Option<PathBuf>,
61    run_id: Option<String>,
62    answers: Option<PathBuf>,
63    registry: Option<String>,
64    expected_digest: Option<String>,
65    json: bool,
66    inspect: bool,
67    force_run: bool,
68    inputs: BTreeMap<String, JsonValue>,
69    credential: Option<CredentialBinding>,
70    credential_profile: Option<String>,
71    secret_env: Option<(String, String)>,
72}
73
74struct CredentialBinding {
75    provider: String,
76    auth_mode: String,
77    material_ref: String,
78    scopes: Vec<String>,
79}
80
81#[derive(Deserialize)]
82#[serde(deny_unknown_fields)]
83struct CredentialProfilesFile {
84    profiles: BTreeMap<String, CredentialProfile>,
85}
86
87#[derive(Deserialize)]
88#[serde(deny_unknown_fields)]
89struct CredentialProfile {
90    credential: String,
91    secret_env: String,
92}
93
94fn parse_credential_binding(value: &str) -> Result<CredentialBinding, String> {
95    let (provider, rest) = value.split_once(':').ok_or_else(credential_usage_error)?;
96    let provider = non_empty_credential_part(provider)?;
97    let (auth_mode, rest) = rest.split_once(':').ok_or_else(credential_usage_error)?;
98    let auth_mode = non_empty_credential_part(auth_mode)?;
99    let (material_ref, scopes) = split_material_ref_and_scopes(rest)?;
100    Ok(CredentialBinding {
101        provider: provider.to_owned(),
102        auth_mode: auth_mode.to_owned(),
103        material_ref,
104        scopes,
105    })
106}
107
108fn split_material_ref_and_scopes(value: &str) -> Result<(String, Vec<String>), String> {
109    let value = non_empty_credential_part(value)?;
110    let Some(index) = value.rfind(':') else {
111        return Ok((value.to_owned(), Vec::new()));
112    };
113    if value[index..].starts_with("://") {
114        return Ok((value.to_owned(), Vec::new()));
115    }
116    let material_ref = non_empty_credential_part(&value[..index])?.to_owned();
117    let scopes = value[index + 1..]
118        .split(',')
119        .map(str::trim)
120        .filter(|scope| !scope.is_empty())
121        .map(ToOwned::to_owned)
122        .collect();
123    Ok((material_ref, scopes))
124}
125
126fn non_empty_credential_part(value: &str) -> Result<&str, String> {
127    let value = value.trim();
128    if value.is_empty() {
129        return Err(credential_usage_error());
130    }
131    Ok(value)
132}
133
134fn credential_usage_error() -> String {
135    "runx skill --credential requires <provider>:<auth_mode>:<material_ref>".to_owned()
136}
137
138fn parse_secret_env(value: &str) -> Result<(String, String), String> {
139    parse_secret_env_from(value, |name| env::var(name).ok())
140}
141
142fn parse_secret_env_from(
143    value: &str,
144    lookup: impl Fn(&str) -> Option<String>,
145) -> Result<(String, String), String> {
146    if value.contains('=') {
147        return Err(
148            "runx skill --secret-env accepts an environment variable name, not an inline value"
149                .to_owned(),
150        );
151    }
152    let name = value.trim();
153    if name.is_empty() {
154        return Err("runx skill --secret-env requires a non-empty env var name".to_owned());
155    }
156    let secret = lookup(name)
157        .ok_or_else(|| format!("runx skill --secret-env env var '{name}' is not set"))?;
158    if secret.trim().is_empty() {
159        return Err("runx skill --secret-env requires a non-empty secret value".to_owned());
160    }
161    Ok((name.to_owned(), secret.to_owned()))
162}
163
164fn finalize_local_credential(
165    state: &SkillParseState,
166    env: &BTreeMap<String, String>,
167    cwd: &Path,
168) -> Result<Option<LocalCredentialDescriptor>, String> {
169    if let Some(profile) = state.credential_profile.as_ref() {
170        if state.credential.is_some() || state.secret_env.is_some() {
171            return Err(
172                "runx skill --credential-profile cannot be combined with --credential or --secret-env"
173                    .to_owned(),
174            );
175        }
176        let profile = load_credential_profile(profile, env, cwd)?;
177        let credential = parse_credential_binding(&profile.credential)?;
178        let secret_env = parse_secret_env_from(&profile.secret_env, |name| env.get(name).cloned())?;
179        return Ok(Some(local_credential_descriptor(&credential, &secret_env)));
180    }
181    match (&state.credential, &state.secret_env) {
182        (None, None) => Ok(None),
183        (Some(_), None) => {
184            Err("runx skill --credential requires --secret-env <ENV_VAR>".to_owned())
185        }
186        (binding, Some((env_var, secret))) => {
187            let binding = binding.as_ref().ok_or_else(|| {
188                "runx skill --secret-env requires --credential <provider>:<auth_mode>:<material_ref>"
189                    .to_owned()
190            })?;
191            Ok(Some(local_credential_descriptor(
192                binding,
193                &(env_var.clone(), secret.clone()),
194            )))
195        }
196    }
197}
198
199fn local_credential_descriptor(
200    binding: &CredentialBinding,
201    secret_env: &(String, String),
202) -> LocalCredentialDescriptor {
203    LocalCredentialDescriptor {
204        provider: binding.provider.clone(),
205        auth_mode: binding.auth_mode.clone(),
206        env_var: secret_env.0.clone(),
207        material_ref: binding.material_ref.clone(),
208        scopes: binding.scopes.clone(),
209        secret: secret_env.1.clone(),
210    }
211}
212
213fn load_credential_profile(
214    profile_name: &str,
215    env: &BTreeMap<String, String>,
216    cwd: &Path,
217) -> Result<CredentialProfile, String> {
218    let profile_name = profile_name.trim();
219    if profile_name.is_empty() {
220        return Err("runx skill --credential-profile requires a non-empty name".to_owned());
221    }
222    let paths = credential_profile_paths(env, cwd);
223    for path in &paths {
224        let contents = match fs::read_to_string(path) {
225            Ok(contents) => contents,
226            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
227            Err(error) => {
228                return Err(format!(
229                    "runx skill could not read credential profile file {}: {error}",
230                    path.display()
231                ));
232            }
233        };
234        let parsed: CredentialProfilesFile = serde_json::from_str(&contents).map_err(|error| {
235            format!(
236                "runx skill credential profile file {} is invalid JSON: {error}",
237                path.display()
238            )
239        })?;
240        if let Some(profile) = parsed.profiles.into_iter().find_map(|(name, profile)| {
241            if name == profile_name {
242                Some(profile)
243            } else {
244                None
245            }
246        }) {
247            return Ok(profile);
248        }
249    }
250    let searched = paths
251        .iter()
252        .map(|path| path.display().to_string())
253        .collect::<Vec<_>>()
254        .join(", ");
255    Err(format!(
256        "runx skill credential profile '{profile_name}' was not found; searched {searched}"
257    ))
258}
259
260fn credential_profile_paths(env: &BTreeMap<String, String>, cwd: &Path) -> Vec<PathBuf> {
261    if let Some(path) = env
262        .get("RUNX_CREDENTIAL_PROFILES")
263        .filter(|value| !value.trim().is_empty())
264    {
265        return vec![resolve_path_from_user_input(path, env, cwd, true)];
266    }
267    let mut paths = Vec::new();
268    if let Some(project_dir) = env
269        .get("RUNX_PROJECT_DIR")
270        .filter(|value| !value.trim().is_empty())
271        .map(|value| resolve_path_from_user_input(value, env, cwd, true))
272        .or_else(|| nearest_project_runx_dir(cwd))
273    {
274        paths.push(project_dir.join("credentials.json"));
275    }
276    paths.push(resolve_runx_home_dir(env, cwd).join("credentials.json"));
277    paths.dedup();
278    paths
279}
280
281fn nearest_project_runx_dir(cwd: &Path) -> Option<PathBuf> {
282    cwd.ancestors()
283        .map(|ancestor| ancestor.join(".runx"))
284        .find(|candidate| candidate.is_dir())
285}
286
287fn reject_resolver_flags_for_skill_management_action(
288    skill_path: &Path,
289    state: &SkillParseState,
290) -> Result<(), String> {
291    if state.registry.is_none() && state.expected_digest.is_none() {
292        return Ok(());
293    }
294    if !is_skill_management_action(skill_path) {
295        return Ok(());
296    }
297    Err("runx skill --registry and --digest are only supported when running a skill ref".to_owned())
298}
299
300fn is_skill_management_action(skill_path: &Path) -> bool {
301    if skill_path.components().count() != 1 {
302        return false;
303    }
304    matches!(
305        skill_path.to_str(),
306        Some("add" | "inspect" | "publish" | "search" | "validate")
307    )
308}
309
310// rust-style-allow: long-function because this is the single skill-flag dispatch
311// match (--receipt-dir/--json/--credential and positionals); splitting the
312// arms would scatter the CLI parse contract.
313fn parse_skill_arg(
314    args: &[OsString],
315    mut index: usize,
316    state: &mut SkillParseState,
317) -> Result<usize, String> {
318    let token = string_arg(args, index)?;
319    if is_retired_skill_option(&token) {
320        return Err(
321            "retired runx skill receipt option is not supported; use --receipt-dir".to_owned(),
322        );
323    }
324    match token.as_str() {
325        value if value.starts_with("--receipt-dir=") => {
326            state.receipt_dir = Some(PathBuf::from(value.trim_start_matches("--receipt-dir=")));
327        }
328        value if value.starts_with("-R=") => {
329            state.receipt_dir = Some(PathBuf::from(value.trim_start_matches("-R=")));
330        }
331        value if value.starts_with("--receipts=") => {
332            state.receipt_dir = Some(PathBuf::from(value.trim_start_matches("--receipts=")));
333        }
334        "--receipt-dir" => {
335            index += 1;
336            state.receipt_dir = Some(PathBuf::from(string_arg(args, index)?));
337        }
338        "--receipts" => {
339            index += 1;
340            state.receipt_dir = Some(PathBuf::from(string_arg(args, index)?));
341        }
342        "-R" => {
343            index += 1;
344            state.receipt_dir = Some(PathBuf::from(string_arg(args, index)?));
345        }
346        value if value.starts_with("--run-id=") || value == "--run-id" => {
347            return Err(skill_resume_flag_error());
348        }
349        value if value.starts_with("--answers=") || value == "--answers" => {
350            return Err(skill_resume_flag_error());
351        }
352        value if value.starts_with("--runner=") || value == "--runner" => {
353            return Err(
354                "runx skill --runner is no longer supported; use `runx skill <skill> <runner>`"
355                    .to_owned(),
356            );
357        }
358        value if value.starts_with("--registry=") => {
359            state.registry = Some(non_empty_flag_value(
360                "--registry",
361                value.trim_start_matches("--registry="),
362            )?);
363        }
364        "--registry" => {
365            index += 1;
366            state.registry = Some(non_empty_flag_value(
367                "--registry",
368                &string_arg(args, index)?,
369            )?);
370        }
371        value if value.starts_with("--digest=") => {
372            state.expected_digest = Some(non_empty_flag_value(
373                "--digest",
374                value.trim_start_matches("--digest="),
375            )?);
376        }
377        "--digest" => {
378            index += 1;
379            state.expected_digest =
380                Some(non_empty_flag_value("--digest", &string_arg(args, index)?)?);
381        }
382        value if value.starts_with("--input=") => {
383            index = parse_input_arg(
384                args,
385                index,
386                Some(value.trim_start_matches("--input=")),
387                &mut state.inputs,
388            )?;
389        }
390        value if value.starts_with("--input-json=") => {
391            index = parse_json_input_arg(
392                args,
393                index,
394                Some(value.trim_start_matches("--input-json=")),
395                &mut state.inputs,
396            )?;
397        }
398        value if value.starts_with("-i=") => {
399            index = parse_input_arg(
400                args,
401                index,
402                Some(value.trim_start_matches("-i=")),
403                &mut state.inputs,
404            )?;
405        }
406        "--input" => index = parse_input_arg(args, index, None, &mut state.inputs)?,
407        "--input-json" => index = parse_json_input_arg(args, index, None, &mut state.inputs)?,
408        "-i" => index = parse_input_arg(args, index, None, &mut state.inputs)?,
409        "--run" => state.force_run = true,
410        value if value.starts_with("--credential=") => {
411            state.credential = Some(parse_credential_binding(
412                value.trim_start_matches("--credential="),
413            )?);
414        }
415        "--credential" => {
416            index += 1;
417            state.credential = Some(parse_credential_binding(&string_arg(args, index)?)?);
418        }
419        value if value.starts_with("--credential-profile=") => {
420            state.credential_profile = Some(non_empty_flag_value(
421                "--credential-profile",
422                value.trim_start_matches("--credential-profile="),
423            )?);
424        }
425        value if value.starts_with("--profile=") => {
426            state.credential_profile = Some(non_empty_flag_value(
427                "--profile",
428                value.trim_start_matches("--profile="),
429            )?);
430        }
431        "--credential-profile" => {
432            index += 1;
433            state.credential_profile = Some(non_empty_flag_value(
434                "--credential-profile",
435                &string_arg(args, index)?,
436            )?);
437        }
438        "--profile" | "-p" => {
439            index += 1;
440            state.credential_profile = Some(non_empty_flag_value(
441                "--profile",
442                &string_arg(args, index)?,
443            )?);
444        }
445        value if value.starts_with("--secret-env=") => {
446            state.secret_env = Some(parse_secret_env(value.trim_start_matches("--secret-env="))?);
447        }
448        "--secret-env" => {
449            index += 1;
450            state.secret_env = Some(parse_secret_env(&string_arg(args, index)?)?);
451        }
452        "--json" | "-j" => state.json = true,
453        "--non-interactive" => {}
454        value if value.starts_with("--") => {
455            index = parse_direct_input_arg(args, index, value, &mut state.inputs)?;
456        }
457        value => {
458            if state.skill_path.is_none() && value == "inspect" {
459                state.inspect = true;
460            } else if state.skill_path.is_none() {
461                state.skill_path = Some(PathBuf::from(value));
462            } else if state.runner.is_none() {
463                state.runner = Some(value.to_owned());
464            } else {
465                return Err(format!("unexpected runx skill argument {value}"));
466            }
467        }
468    }
469    Ok(index)
470}
471
472fn non_empty_flag_value(flag: &str, value: &str) -> Result<String, String> {
473    let value = value.trim();
474    if value.is_empty() {
475        return Err(format!("runx skill {flag} requires a non-empty value"));
476    }
477    Ok(value.to_owned())
478}
479
480fn skill_resume_flag_error() -> String {
481    "runx skill continuation flags are no longer supported; use `runx resume <run-id> <answers.json>`".to_owned()
482}
483
484fn is_retired_skill_option(token: &str) -> bool {
485    let Some(flag) = token.strip_prefix("--") else {
486        return false;
487    };
488    let name = flag.split_once('=').map_or(flag, |(name, _value)| name);
489    name == "receipt" || name == retired_receipt_dir_option_name()
490}
491
492fn retired_receipt_dir_option_name() -> String {
493    ["receipt", "Dir"].concat()
494}
495
496fn string_arg(args: &[OsString], index: usize) -> Result<String, String> {
497    let value = args
498        .get(index)
499        .ok_or_else(|| "missing value for runx skill argument".to_owned())?;
500    value
501        .to_str()
502        .map(ToOwned::to_owned)
503        .ok_or_else(|| "runx skill arguments must be UTF-8".to_owned())
504}
505
506#[cfg(test)]
507mod tests {
508    use std::fs;
509    use std::time::{SystemTime, UNIX_EPOCH};
510
511    use super::{SkillAction, SkillParseState, finalize_local_credential};
512
513    #[test]
514    fn credential_profile_resolves_project_descriptor_and_env_secret() -> Result<(), String> {
515        let root = unique_temp_dir("runx-credential-profile")?;
516        let runx_dir = root.join(".runx");
517        fs::create_dir_all(&runx_dir).map_err(|error| error.to_string())?;
518        fs::write(
519            runx_dir.join("credentials.json"),
520            r#"{
521  "profiles": {
522    "example": {
523      "credential": "example:bearer:local://example/internal:example.review",
524      "secret_env": "INTERNAL_SYNC_SECRET"
525    }
526  }
527}
528"#,
529        )
530        .map_err(|error| error.to_string())?;
531
532        let state = SkillParseState {
533            credential_profile: Some("example".to_owned()),
534            ..Default::default()
535        };
536        let env = [
537            (
538                "RUNX_PROJECT_DIR".to_owned(),
539                runx_dir.to_string_lossy().into_owned(),
540            ),
541            ("INTERNAL_SYNC_SECRET".to_owned(), "secret-value".to_owned()),
542        ]
543        .into_iter()
544        .collect();
545        let credential = finalize_local_credential(&state, &env, &root)?
546            .ok_or_else(|| "credential profile did not resolve".to_owned())?;
547
548        assert_eq!(credential.provider, "example");
549        assert_eq!(credential.auth_mode, "bearer");
550        assert_eq!(credential.material_ref, "local://example/internal");
551        assert_eq!(credential.env_var, "INTERNAL_SYNC_SECRET");
552        assert_eq!(credential.secret, "secret-value");
553        assert_eq!(credential.scopes, vec!["example.review"]);
554
555        fs::remove_dir_all(root).map_err(|error| error.to_string())?;
556        Ok(())
557    }
558
559    #[test]
560    fn credential_profile_rejects_manual_credential_flags() -> Result<(), String> {
561        let state = SkillParseState {
562            credential_profile: Some("example".to_owned()),
563            secret_env: Some(("TOKEN".to_owned(), "secret".to_owned())),
564            ..Default::default()
565        };
566        let error = finalize_local_credential(&state, &Default::default(), &std::env::temp_dir())
567            .err()
568            .ok_or_else(|| "profile unexpectedly combined with manual flags".to_owned())?;
569        assert!(error.contains("cannot be combined"));
570        Ok(())
571    }
572
573    #[test]
574    fn short_human_flags_parse_for_skill_runs() -> Result<(), String> {
575        let args = [
576            "skill",
577            "-p",
578            "operator",
579            "-i",
580            "claim=abc",
581            "-R",
582            "receipts",
583            "-j",
584        ]
585        .into_iter()
586        .map(std::ffi::OsString::from)
587        .collect::<Vec<_>>();
588        let mut state = SkillParseState::default();
589        let mut index = 1;
590        while index < args.len() {
591            index = super::parse_skill_arg(&args, index, &mut state)?;
592            index += 1;
593        }
594        assert_eq!(state.credential_profile.as_deref(), Some("operator"));
595        assert_eq!(
596            state.inputs.get("claim"),
597            Some(&runx_contracts::JsonValue::String("abc".to_owned()))
598        );
599        assert_eq!(
600            state.receipt_dir.as_deref(),
601            Some(std::path::Path::new("receipts"))
602        );
603        assert!(state.json);
604        Ok(())
605    }
606
607    #[test]
608    fn input_json_parses_strict_json_values() -> Result<(), String> {
609        let args = [
610            "skill",
611            "skills/data-store",
612            "--input-json",
613            "event",
614            r#"{"type":"posting.claimed","payload":{"actor":"agent-9"}}"#,
615            "--input-json=limits={\"rows\":10}",
616        ]
617        .into_iter()
618        .map(std::ffi::OsString::from)
619        .collect::<Vec<_>>();
620        let plan = super::parse_skill_plan(&args)?;
621        assert_eq!(plan.action, SkillAction::Run);
622
623        assert_eq!(
624            plan.inputs
625                .get("event")
626                .and_then(runx_contracts::JsonValue::as_object)
627                .and_then(|event| event.get("type"))
628                .and_then(runx_contracts::JsonValue::as_str),
629            Some("posting.claimed")
630        );
631        let rows = plan
632            .inputs
633            .get("limits")
634            .and_then(runx_contracts::JsonValue::as_object)
635            .and_then(|limits| limits.get("rows"))
636            .and_then(|rows| match rows {
637                runx_contracts::JsonValue::Number(number) => number.as_f64(),
638                _ => None,
639            });
640        assert_eq!(rows, Some(10.0));
641        Ok(())
642    }
643
644    #[test]
645    fn skill_without_inputs_executes_default_runner() -> Result<(), String> {
646        let args = ["skill", "skills/messageboard"]
647            .into_iter()
648            .map(std::ffi::OsString::from)
649            .collect::<Vec<_>>();
650        let plan = super::parse_skill_plan(&args)?;
651
652        assert_eq!(plan.action, SkillAction::Run);
653        assert_eq!(
654            plan.skill_path,
655            std::path::PathBuf::from("skills/messageboard")
656        );
657        assert_eq!(plan.runner, None);
658        Ok(())
659    }
660
661    #[test]
662    fn positional_runner_without_inputs_executes_runner() -> Result<(), String> {
663        let args = ["skill", "skills/messageboard", "post_and_append"]
664            .into_iter()
665            .map(std::ffi::OsString::from)
666            .collect::<Vec<_>>();
667        let plan = super::parse_skill_plan(&args)?;
668
669        assert_eq!(plan.action, SkillAction::Run);
670        assert_eq!(plan.runner.as_deref(), Some("post_and_append"));
671        Ok(())
672    }
673
674    #[test]
675    fn explicit_inspect_returns_skill_card() -> Result<(), String> {
676        let args = ["skill", "inspect", "skills/messageboard", "post_and_append"]
677            .into_iter()
678            .map(std::ffi::OsString::from)
679            .collect::<Vec<_>>();
680        let plan = super::parse_skill_plan(&args)?;
681
682        assert_eq!(plan.action, SkillAction::Inspect);
683        assert_eq!(
684            plan.skill_path,
685            std::path::PathBuf::from("skills/messageboard")
686        );
687        assert_eq!(plan.runner.as_deref(), Some("post_and_append"));
688        Ok(())
689    }
690
691    #[test]
692    fn positional_runner_with_inputs_executes_runner() -> Result<(), String> {
693        let args = [
694            "skill",
695            "skills/messageboard",
696            "post_and_append",
697            "-i",
698            "title=hello",
699        ]
700        .into_iter()
701        .map(std::ffi::OsString::from)
702        .collect::<Vec<_>>();
703        let plan = super::parse_skill_plan(&args)?;
704
705        assert_eq!(plan.action, SkillAction::Run);
706        assert_eq!(plan.runner.as_deref(), Some("post_and_append"));
707        assert_eq!(
708            plan.inputs.get("title"),
709            Some(&runx_contracts::JsonValue::String("hello".to_owned()))
710        );
711        Ok(())
712    }
713
714    #[test]
715    fn run_flag_executes_zero_input_runner() -> Result<(), String> {
716        let args = ["skill", "skills/ops-desk", "refresh", "--run"]
717            .into_iter()
718            .map(std::ffi::OsString::from)
719            .collect::<Vec<_>>();
720        let plan = super::parse_skill_plan(&args)?;
721
722        assert_eq!(plan.action, SkillAction::Run);
723        assert_eq!(plan.runner.as_deref(), Some("refresh"));
724        Ok(())
725    }
726
727    #[test]
728    fn input_json_rejects_non_json_values() -> Result<(), String> {
729        let args = [
730            "skill",
731            "skills/data-store",
732            "--input-json",
733            "event",
734            "plain text",
735        ]
736        .into_iter()
737        .map(std::ffi::OsString::from)
738        .collect::<Vec<_>>();
739        let Err(error) = super::parse_skill_plan(&args) else {
740            return Err("invalid json input should fail".to_owned());
741        };
742
743        assert!(error.contains("--input-json event is invalid JSON"));
744        Ok(())
745    }
746
747    #[test]
748    fn credential_parser_keeps_uri_material_ref_intact() -> Result<(), String> {
749        let binding = super::parse_credential_binding(
750            "frantic:bearer:local://frantic/internal:frantic.review,frantic.write",
751        )?;
752        assert_eq!(binding.provider, "frantic");
753        assert_eq!(binding.auth_mode, "bearer");
754        assert_eq!(binding.material_ref, "local://frantic/internal");
755        assert_eq!(binding.scopes, vec!["frantic.review", "frantic.write"]);
756        Ok(())
757    }
758
759    fn unique_temp_dir(name: &str) -> Result<std::path::PathBuf, String> {
760        let nanos = SystemTime::now()
761            .duration_since(UNIX_EPOCH)
762            .map_err(|error| error.to_string())?
763            .as_nanos();
764        let path = std::env::temp_dir().join(format!("{name}-{}-{nanos}", std::process::id()));
765        fs::create_dir_all(&path).map_err(|error| error.to_string())?;
766        Ok(path)
767    }
768}