Skip to main content

runx_cli/
router.rs

1// rust-style-allow: large-file - router argument parity is centralized for CLI routing tests.
2use std::ffi::{OsStr, OsString};
3use std::path::PathBuf;
4
5use crate::cli_args::{flag_value, optional_flag_value, os_arg, split_flag};
6use crate::config::ConfigPlan;
7use crate::export::ExportPlan;
8use crate::kernel::{KernelInputSource, KernelPlan};
9use crate::login::LoginPlan;
10use crate::mcp::McpPlan;
11use crate::parser::{ParserInputSource, ParserPlan};
12use crate::payment::{PaymentAction, PaymentAdmissionPlan, PaymentInputSource, PaymentPlan};
13use crate::policy::{PolicyAction, PolicyPlan};
14use crate::publish::PublishPlan;
15use crate::registry::{RegistryAction, RegistryPlan};
16use crate::resume::ResumePlan;
17use crate::skill::SkillPlan;
18use runx_runtime::registry::parse_registry_ref;
19
20#[derive(Debug, PartialEq)]
21pub enum RouterAction {
22    Error(String),
23    JsonError(JsonErrorPlan),
24    RunDev(DevPlan),
25    RunDoctor(DoctorPlan),
26    RunExport(ExportPlan),
27    RunInit(InitPlan),
28    RunList(ListPlan),
29    RunLogin(LoginPlan),
30    RunMcp(McpPlan),
31    RunParser(ParserPlan),
32    RunNew(NewPlan),
33    RunHistory(HistoryPlan),
34    RunVerify(VerifyPlan),
35    RunHarness(HarnessPlan),
36    RunKernel(KernelPlan),
37    RunPayment(PaymentPlan),
38    RunConfig(ConfigPlan),
39    RunPolicy(PolicyPlan),
40    RunPublish(PublishPlan),
41    RunRegistry(RegistryPlan),
42    RunResume(ResumePlan),
43    RunSkill(SkillPlan),
44    RunTool(ToolPlan),
45    RunAddUrl(AddUrlPlan),
46    PrintHelp,
47    PrintAddHelp,
48    PrintHistoryHelp,
49    PrintListHelp,
50    PrintLoginHelp,
51    PrintPublishHelp,
52    PrintRegistryHelp,
53    PrintRegistryUsageError,
54    PrintResumeHelp,
55    PrintSkillHelp,
56    PrintVerifyHelp,
57    PrintVersion,
58}
59
60#[derive(Debug, Eq, PartialEq)]
61pub struct JsonErrorPlan {
62    pub message: String,
63    pub code: String,
64    pub exit_code: u8,
65}
66
67/// Arguments for indexing a GitHub repository via `runx add <github-url>`.
68#[derive(Debug, Eq, PartialEq)]
69pub struct AddUrlPlan {
70    pub repo: String,
71    pub repo_ref: Option<String>,
72    pub api_base_url: Option<String>,
73    pub json: bool,
74}
75
76#[derive(Debug, Eq, PartialEq)]
77pub struct DevPlan {
78    pub root: Option<PathBuf>,
79    pub lane: Option<String>,
80    pub json: bool,
81}
82
83#[derive(Debug, Eq, PartialEq)]
84pub struct HarnessPlan {
85    /// Replay targets: standalone fixture `.yaml` files, or a skill package
86    /// (directory / `SKILL.md`) whose declared inline `harness.cases` are run.
87    pub fixture_paths: Vec<OsString>,
88    /// Where receipts the cases seal are written (`--receipt-dir`).
89    pub receipt_dir: Option<OsString>,
90}
91
92#[derive(Debug, Eq, PartialEq)]
93pub struct HistoryPlan {
94    pub args: Vec<OsString>,
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct VerifyPlan {
99    pub args: Vec<OsString>,
100}
101
102#[derive(Debug, Eq, PartialEq)]
103pub struct DoctorPlan {
104    pub mode: DoctorMode,
105    pub path: Option<PathBuf>,
106    pub json: bool,
107}
108
109#[derive(Debug, Eq, PartialEq)]
110pub enum DoctorMode {
111    Workspace,
112    Authority,
113    Registry,
114}
115
116#[derive(Debug, Eq, PartialEq)]
117pub struct ListPlan {
118    pub kind: ListKind,
119    pub filter: FilterMode,
120    pub json: bool,
121}
122
123#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
124pub enum FilterMode {
125    #[default]
126    All,
127    OkOnly,
128    InvalidOnly,
129}
130
131#[derive(Clone, Copy, Debug, Eq, PartialEq)]
132pub enum ListKind {
133    All,
134    Tools,
135    Skills,
136    Graphs,
137    Packets,
138    Overlays,
139}
140
141#[derive(Debug, Eq, PartialEq)]
142pub struct NewPlan {
143    pub name: String,
144    pub directory: Option<PathBuf>,
145    pub json: bool,
146}
147
148#[derive(Debug, Eq, PartialEq)]
149pub struct InitPlan {
150    pub global: bool,
151    pub prefetch_official: bool,
152    pub json: bool,
153}
154
155#[derive(Debug, Eq, PartialEq)]
156pub struct ToolPlan {
157    pub action: ToolAction,
158    pub path: Option<PathBuf>,
159    pub ref_or_query: Option<String>,
160    pub all: bool,
161    pub source: Option<String>,
162    pub json: bool,
163}
164
165#[derive(Debug, Eq, PartialEq)]
166pub enum ToolAction {
167    Build,
168    Search,
169    Inspect,
170}
171
172// rust-style-allow: long-function because router routing is the cutover gate:
173// every native command branch and fail-closed decision is reviewed here.
174pub fn route_args(args: Vec<OsString>) -> RouterAction {
175    if args.is_empty() || single_arg_is(&args, "--help") || single_arg_is(&args, "-h") {
176        return RouterAction::PrintHelp;
177    }
178
179    if single_arg_is(&args, "--version") || single_arg_is(&args, "-V") {
180        return RouterAction::PrintVersion;
181    }
182
183    if first_arg_is(&args, "harness") {
184        return native_harness_plan(&args);
185    }
186
187    if first_arg_is(&args, "config") {
188        return crate::config::parse_config_plan(&args)
189            .map_or_else(RouterAction::Error, RouterAction::RunConfig);
190    }
191
192    if first_arg_is(&args, "login") {
193        if nested_help_requested(&args) {
194            return RouterAction::PrintLoginHelp;
195        }
196        return crate::login::parse_login_plan(&args)
197            .map_or_else(RouterAction::Error, RouterAction::RunLogin);
198    }
199
200    if first_arg_is(&args, "policy") {
201        return parse_policy_plan(&args).map_or_else(RouterAction::Error, RouterAction::RunPolicy);
202    }
203
204    if first_arg_is(&args, "publish") {
205        if nested_help_requested(&args) {
206            return RouterAction::PrintPublishHelp;
207        }
208        return crate::publish::parse_publish_plan(&args)
209            .map_or_else(RouterAction::Error, RouterAction::RunPublish);
210    }
211
212    if first_arg_is(&args, "kernel") {
213        return parse_kernel_plan(&args).map_or_else(RouterAction::Error, RouterAction::RunKernel);
214    }
215
216    if first_arg_is(&args, "payment") {
217        return parse_payment_plan(&args)
218            .map_or_else(RouterAction::Error, RouterAction::RunPayment);
219    }
220
221    if first_arg_is(&args, "parser") {
222        return parse_parser_plan(&args).map_or_else(RouterAction::Error, RouterAction::RunParser);
223    }
224
225    if first_arg_is(&args, "doctor") {
226        return parse_doctor_plan(&args).map_or_else(RouterAction::Error, RouterAction::RunDoctor);
227    }
228
229    if first_arg_is(&args, "dev") {
230        return parse_dev_plan(&args).map_or_else(RouterAction::Error, RouterAction::RunDev);
231    }
232
233    if first_arg_is(&args, "export") {
234        if args.len() == 2
235            && args
236                .get(1)
237                .and_then(|arg| arg.to_str())
238                .is_some_and(|arg| matches!(arg, "--help" | "-h"))
239        {
240            return RouterAction::PrintHelp;
241        }
242        return crate::export::parse_export_plan(&args)
243            .map_or_else(RouterAction::Error, RouterAction::RunExport);
244    }
245
246    if first_arg_is(&args, "list") {
247        if nested_help_requested(&args) {
248            return RouterAction::PrintListHelp;
249        }
250        return parse_list_plan(&args).map_or_else(RouterAction::Error, RouterAction::RunList);
251    }
252
253    if first_arg_is(&args, "new") {
254        return parse_new_plan(&args).map_or_else(RouterAction::Error, RouterAction::RunNew);
255    }
256
257    if first_arg_is(&args, "init") {
258        return parse_init_plan(&args).map_or_else(RouterAction::Error, RouterAction::RunInit);
259    }
260
261    if first_arg_is(&args, "history") {
262        if nested_help_requested(&args) {
263            return RouterAction::PrintHistoryHelp;
264        }
265        return RouterAction::RunHistory(HistoryPlan { args });
266    }
267
268    if first_arg_is(&args, "resume") {
269        if nested_help_requested(&args) {
270            return RouterAction::PrintResumeHelp;
271        }
272        return crate::resume::parse_resume_plan(&args).map_or_else(
273            |message| json_or_human_error(&args, message),
274            RouterAction::RunResume,
275        );
276    }
277
278    if first_arg_is(&args, "verify") {
279        if nested_help_requested(&args) {
280            return RouterAction::PrintVerifyHelp;
281        }
282        return RouterAction::RunVerify(VerifyPlan { args });
283    }
284
285    if first_arg_is(&args, "mcp") {
286        if mcp_runner_before_serve(&args) {
287            return RouterAction::Error(
288                "runx mcp --runner must follow the serve subcommand".to_owned(),
289            );
290        }
291        return crate::mcp::parse_mcp_plan(&args)
292            .map_or_else(RouterAction::Error, RouterAction::RunMcp);
293    }
294
295    if first_arg_is(&args, "tool") {
296        return parse_tool_plan(&args).map_or_else(RouterAction::Error, RouterAction::RunTool);
297    }
298
299    if first_arg_is(&args, "registry") {
300        if nested_help_requested(&args) {
301            return RouterAction::PrintRegistryHelp;
302        }
303        if args.len() == 1 {
304            return RouterAction::PrintRegistryUsageError;
305        }
306        return parse_registry_plan(&args).map_or_else(
307            |message| json_or_human_error(&args, message),
308            RouterAction::RunRegistry,
309        );
310    }
311
312    if first_arg_is(&args, "add") {
313        if nested_help_requested(&args) {
314            return RouterAction::PrintAddHelp;
315        }
316        return parse_add_plan(&args).unwrap_or_else(|message| json_or_human_error(&args, message));
317    }
318
319    if first_arg_is(&args, "skill") {
320        if nested_help_requested(&args) {
321            return RouterAction::PrintSkillHelp;
322        }
323        if second_arg_is(&args, "add") {
324            return json_or_human_error(
325                &args,
326                "runx skill add has been removed; use runx add <ref>".to_owned(),
327            );
328        }
329        return crate::skill::parse_skill_plan(&args).map_or_else(
330            |message| json_or_human_error(&args, message),
331            RouterAction::RunSkill,
332        );
333    }
334
335    RouterAction::Error(format!(
336        "unknown command {}",
337        args.first()
338            .and_then(|arg| arg.to_str())
339            .unwrap_or("<non-utf8>")
340    ))
341}
342
343pub fn help_text() -> String {
344    "\
345runx
346
347Usage:
348  runx <command> [args]
349  runx --help
350  runx --version
351
352Commands:
353  runx new <name> [--directory dir] [--json]
354  runx init [-g|--global] [--prefetch official] [--json]
355  runx verify [receipt-id] [--receipt-dir dir] [--receipt <path|->] [--notary <path|-> --notary-key trusted.pem] [-j|--json]
356  runx history [query] [--skill s] [--status s] [--source s] [--actor a] [--artifact-type t] [--since iso] [--until iso] [--receipt-dir dir] [--json]
357  runx resume <run-id> <answers.json> [-R dir] [-j|--json]
358  runx list [tools|skills|graphs|packets|overlays] [--ok-only|--invalid-only] [-j|--json]
359  runx login [--provider github|google|gitlab] [--for default|publish] [--api-base-url url] [--allow-local-api] [-j|--json]
360  runx config set|get|list [provider|model|api-key|public-token] [value] [-j|--json]
361  runx policy inspect|lint <policy.json> [--json]
362  runx publish <receipt.json> [--api-base-url url] [--token token] [--allow-local-api] [-j|--json]
363  runx kernel eval --input <file|-> --json
364  runx payment admission issue --input <file|-> --json
365  runx parser eval --input <file|-> --json
366  runx doctor [path|authority|registry] [--json]
367  runx dev [root] [--lane lane] [--json]
368  runx export <claude|codex> [skill-ref...] [--project] [--json]
369  runx mcp serve <skill-ref...> [--receipt-dir dir] [--http-listen [addr]] [--http-allow-non-loopback]
370  runx skill <skill-ref|owner/name@version|skill-dir|SKILL.md> [runner] [-p profile] [-i key=value] [--input-json key=json] [-j] [--registry url|path] [--digest sha256] [--flag value] [--credential descriptor --secret-env NAME] [-R dir]
371  runx skill inspect <skill-ref|owner/name@version|skill-dir|SKILL.md> [runner] [-j] [--registry url|path] [--digest sha256]
372  runx add <skill-ref|github-url> [--registry url|path] [--version version] [--ref git-ref] [--digest sha256] [--to dir] [--api-base-url url] [--json]
373  runx harness <fixture.yaml...|skill-dir|SKILL.md> [-R dir] [-j|--json]
374  runx tool build <tool-dir>|--all [--json]
375  runx tool search <query> [--source source] [--json]
376  runx tool inspect <ref> [--source source] [--json]
377  runx registry search|read|resolve|install|publish ... --json
378"
379    .to_owned()
380}
381
382pub fn history_help_text() -> String {
383    "\
384runx history
385
386Usage:
387  runx history [query] [--skill s] [--status s] [--source s] [--actor a] [--artifact-type t] [--since iso] [--until iso] [--receipt-dir dir] [--json]
388
389Options:
390  --skill s
391  --status s
392  --source s
393  --actor a
394  --artifact-type t
395  --since iso
396  --until iso
397  --receipt-dir dir
398  --json
399"
400    .to_owned()
401}
402
403pub fn resume_help_text() -> String {
404    "\
405runx resume
406
407Usage:
408  runx resume <run-id> <answers.json> [-R dir] [-j|--json]
409
410Options:
411  -R, --receipts dir
412  --receipt-dir dir
413  -j, --json
414"
415    .to_owned()
416}
417
418pub fn list_help_text() -> String {
419    "\
420runx list
421
422Usage:
423  runx list [tools|skills|graphs|packets|overlays] [--ok-only|--invalid-only] [-j|--json]
424
425Options:
426  --ok-only
427  --invalid-only
428  -j, --json
429"
430    .to_owned()
431}
432
433pub fn login_help_text() -> String {
434    "\
435runx login
436
437Usage:
438  runx login [--provider github|google|gitlab] [--for default|publish] [--api-base-url url] [--allow-local-api] [-j|--json]
439
440Options:
441  --provider provider
442  --for purpose
443  --api-base-url url
444  --allow-local-api
445  -j, --json
446"
447    .to_owned()
448}
449
450pub fn publish_help_text() -> String {
451    "\
452runx publish
453
454Usage:
455  runx publish <receipt.json> [--api-base-url url] [--token token] [--allow-local-api] [-j|--json]
456
457Options:
458  --api-base-url url  Public API base URL (default: RUNX_PUBLIC_API_BASE_URL or https://api.runx.ai)
459  --token token       Public API token (default: RUNX_PUBLIC_API_TOKEN or runx login)
460  --allow-local-api   Allow loopback/private public API URLs for local dogfood only
461  -j, --json          Print the raw notary response as JSON
462"
463    .to_owned()
464}
465
466pub fn add_help_text() -> String {
467    "\
468runx add
469
470Usage:
471  runx add <skill-ref|github-url> [--registry url|path] [--version version] [--ref git-ref] [--digest sha256] [--to dir] [--api-base-url url] [--json]
472
473Options:
474  --registry url|path  Registry URL or local registry path for skill refs
475  --version version    Registry version for skill refs
476  --ref git-ref        Git ref for GitHub repository URLs
477  --digest sha256      Expected package digest for skill refs
478  --to dir             Install destination for skill refs
479  --api-base-url url   Hosted index API for GitHub repository URLs
480  -j, --json
481"
482    .to_owned()
483}
484
485pub fn registry_help_text() -> String {
486    "\
487runx registry
488
489Usage:
490  runx registry search <query> [--registry url|path] [--registry-dir dir] [--limit n] [-j|--json]
491  runx registry read <ref> [--registry url|path] [--registry-dir dir] [--version version] [-j|--json]
492  runx registry resolve <ref> [--registry url|path] [--registry-dir dir] [--version version] [-j|--json]
493  runx registry install <ref> [--registry url|path] [--registry-dir dir] [--version version] [--digest sha256] [--to dir] [-j|--json]
494  runx registry publish <SKILL.md|skill-dir> [--registry url|path] [--owner owner] [--version version] [--profile X.yaml] [--trust-tier tier] [--upsert] [-j|--json]
495
496Options:
497  --registry url|path
498  --registry-dir dir
499  --version version
500  --digest sha256
501  --to dir
502  --owner owner
503  --profile X.yaml
504  --trust-tier first_party|verified|community
505  --limit n
506  --upsert
507  -j, --json
508"
509    .to_owned()
510}
511
512pub fn verify_help_text() -> String {
513    "\
514runx verify
515
516Usage:
517  runx verify [receipt-id] [--receipt-dir dir] [--receipt <path|->] [--notary <path|-> --notary-key trusted.pem] [-j|--json]
518
519Options:
520  --receipt-dir dir
521  --receipt <path|->
522  --notary <path|->
523  --notary-key trusted.pem
524  -j, --json
525"
526    .to_owned()
527}
528
529pub fn skill_help_text() -> String {
530    "\
531runx skill
532
533Usage:
534  runx skill <skill-ref|owner/name@version|skill-dir|SKILL.md> [runner] [-p profile] [-i key=value] [--input-json key=json] [-j] [--registry url|path] [--digest sha256] [--flag value] [--credential descriptor --secret-env NAME] [-R dir]
535  runx skill inspect <skill-ref|owner/name@version|skill-dir|SKILL.md> [runner] [-j] [--registry url|path] [--digest sha256]
536
537Options:
538  -p, --profile name       Use a local credential profile from .runx/credentials.json
539  -i, --input key=value    Set a structured input; repeat for multiple inputs
540  --input-json key=json    Set an input that must parse as JSON
541  -R, --receipts dir       Write receipts under dir
542  --receipt-dir dir        Alias for --receipts
543  -j, --json               Print machine-readable output
544  --registry url|path
545  --digest sha256
546  --flag value
547  --credential descriptor  One-shot local credential descriptor
548  --secret-env NAME        Env var holding the one-shot credential secret
549"
550    .to_owned()
551}
552
553pub fn json_failure_output(message: &str, code: &str) -> String {
554    let message = json_string(message, "failed to serialize error message");
555    let code = json_string(code, "runtime_error");
556    format!(
557        "{{\n  \"status\": \"failure\",\n  \"error\": {{\n    \"message\": {message},\n    \"code\": {code}\n  }}\n}}\n"
558    )
559}
560
561pub fn json_requested(args: &[OsString]) -> bool {
562    args.iter().any(|arg| {
563        arg.to_str()
564            .is_some_and(|token| token == "--json" || token.starts_with("--json="))
565    })
566}
567
568fn single_arg_is(args: &[OsString], expected: &str) -> bool {
569    args.len() == 1 && first_arg_is(args, expected)
570}
571
572fn second_arg_is(args: &[OsString], expected: &str) -> bool {
573    args.get(1).is_some_and(|arg| arg == OsStr::new(expected))
574}
575
576fn json_or_human_error(args: &[OsString], message: String) -> RouterAction {
577    if json_requested(args) {
578        RouterAction::JsonError(JsonErrorPlan {
579            message,
580            code: "invalid_args".to_owned(),
581            exit_code: 64,
582        })
583    } else {
584        RouterAction::Error(message)
585    }
586}
587
588fn json_string(value: &str, fallback: &str) -> String {
589    match serde_json::to_string(value) {
590        Ok(value) => value,
591        Err(_) => format!("\"{fallback}\""),
592    }
593}
594
595fn nested_help_requested(args: &[OsString]) -> bool {
596    args.iter()
597        .skip(1)
598        .any(|arg| matches!(arg.to_str(), Some("--help" | "-h")))
599}
600
601fn first_arg_is(args: &[OsString], expected: &str) -> bool {
602    args.first().is_some_and(|arg| arg == OsStr::new(expected))
603}
604
605fn mcp_runner_before_serve(args: &[OsString]) -> bool {
606    args.iter()
607        .skip(1)
608        .take_while(|arg| arg.as_os_str() != OsStr::new("serve"))
609        .any(|arg| {
610            arg.to_str()
611                .is_some_and(|token| token == "--runner" || token.starts_with("--runner="))
612        })
613}
614
615// rust-style-allow: long-function - harness flag parsing stays local to the
616// router boundary so native dispatch does not grow a second parser surface.
617fn native_harness_plan(args: &[OsString]) -> RouterAction {
618    let mut fixture_paths = Vec::new();
619    let mut receipt_dir = None;
620    let mut index = 1;
621
622    while index < args.len() {
623        let Some(token) = args.get(index).and_then(|arg| arg.to_str()) else {
624            return RouterAction::Error("harness arguments must be UTF-8".to_owned());
625        };
626
627        if !token.starts_with('-') {
628            fixture_paths.push(args[index].clone());
629            index += 1;
630            continue;
631        }
632
633        let (flag, inline_value) = split_flag(token);
634        match flag {
635            "--json" | "-j" => {
636                if inline_value.is_some() {
637                    return RouterAction::Error("--json does not take a value".to_owned());
638                }
639                index += 1;
640            }
641            "--receipt-dir" | "--receipts" => match inline_value {
642                Some(value) => {
643                    receipt_dir = Some(OsString::from(value));
644                    index += 1;
645                }
646                None => {
647                    let Some(value) = args.get(index + 1) else {
648                        return RouterAction::Error(
649                            "--receipt-dir requires a directory".to_owned(),
650                        );
651                    };
652                    receipt_dir = Some(value.clone());
653                    index += 2;
654                }
655            },
656            "-R" => {
657                if inline_value.is_some() {
658                    return RouterAction::Error(
659                        "-R requires a separate directory value".to_owned(),
660                    );
661                }
662                let Some(value) = args.get(index + 1) else {
663                    return RouterAction::Error("-R requires a directory".to_owned());
664                };
665                receipt_dir = Some(value.clone());
666                index += 2;
667            }
668            _ => return RouterAction::Error(format!("unknown harness flag {flag}")),
669        }
670    }
671
672    if fixture_paths.is_empty() {
673        return RouterAction::Error(
674            "runx harness requires a fixture path or skill package".to_owned(),
675        );
676    }
677
678    RouterAction::RunHarness(HarnessPlan {
679        fixture_paths,
680        receipt_dir,
681    })
682}
683
684fn parse_new_plan(args: &[OsString]) -> Result<NewPlan, String> {
685    let mut name = None;
686    let mut directory = None;
687    let mut json = false;
688    let mut positional_directory = None;
689    let mut extra_positionals = Vec::new();
690    let mut index = 1;
691
692    while index < args.len() {
693        let token = os_arg(args, index, "new")?;
694        if !token.starts_with("--") {
695            if name.is_none() {
696                name = Some(token.to_owned());
697            } else if positional_directory.is_none() {
698                positional_directory = Some(PathBuf::from(token));
699            } else {
700                extra_positionals.push(token.to_owned());
701            }
702            index += 1;
703            continue;
704        }
705
706        let (flag, inline_value) = split_flag(token);
707        match flag {
708            "--json" => {
709                if inline_value.is_some() {
710                    return Err("--json does not take a value".to_owned());
711                }
712                json = true;
713                index += 1;
714            }
715            "--directory" | "--dir" => {
716                let (value, next_index) = flag_value(args, index, flag, inline_value, "new")?;
717                directory = Some(PathBuf::from(value));
718                index = next_index;
719            }
720            _ => return Err(format!("unknown new flag {flag}")),
721        }
722    }
723
724    if !extra_positionals.is_empty() {
725        return Err("runx new accepts at most one directory argument".to_owned());
726    }
727
728    Ok(NewPlan {
729        name: name.ok_or_else(|| "runx new requires a package name".to_owned())?,
730        directory: directory.or(positional_directory),
731        json,
732    })
733}
734
735fn parse_add_plan(args: &[OsString]) -> Result<RouterAction, String> {
736    let parsed = parse_add_args(args)?;
737    if is_github_repo_url_like(parsed.subject.as_deref().unwrap_or_default()) {
738        return add_url_plan(parsed).map(RouterAction::RunAddUrl);
739    }
740    add_registry_plan(parsed).map(RouterAction::RunRegistry)
741}
742
743#[derive(Default)]
744struct AddParseState {
745    subject: Option<String>,
746    registry: Option<String>,
747    version: Option<String>,
748    repo_ref: Option<String>,
749    expected_digest: Option<String>,
750    destination: Option<PathBuf>,
751    api_base_url: Option<String>,
752    json: bool,
753}
754
755fn parse_add_args(args: &[OsString]) -> Result<AddParseState, String> {
756    let mut parsed = AddParseState::default();
757    let mut index = 1;
758    while index < args.len() {
759        let token = os_arg(args, index, "add")?;
760        if !token.starts_with("--") {
761            if parsed.subject.is_some() {
762                return Err("runx add accepts exactly one ref or repository URL".to_owned());
763            }
764            parsed.subject = Some(token.to_owned());
765            index += 1;
766            continue;
767        }
768
769        let (flag, inline_value) = split_flag(token);
770        index = parse_add_flag(&mut parsed, args, index, flag, inline_value)?;
771    }
772    if parsed.subject.is_none() {
773        return Err("runx add requires a skill ref or repository URL".to_owned());
774    }
775    Ok(parsed)
776}
777
778fn parse_add_flag(
779    parsed: &mut AddParseState,
780    args: &[OsString],
781    index: usize,
782    flag: &str,
783    inline_value: Option<&str>,
784) -> Result<usize, String> {
785    match flag {
786        "--json" => {
787            if inline_value.is_some() {
788                return Err("--json does not take a value".to_owned());
789            }
790            parsed.json = true;
791            Ok(index + 1)
792        }
793        "--registry" => set_add_string(args, index, flag, inline_value, &mut parsed.registry),
794        "--version" => set_add_string(args, index, flag, inline_value, &mut parsed.version),
795        "--ref" => set_add_string(args, index, flag, inline_value, &mut parsed.repo_ref),
796        "--digest" => set_add_string(args, index, flag, inline_value, &mut parsed.expected_digest),
797        "--to" => {
798            let (value, next_index) = flag_value(args, index, flag, inline_value, "add")?;
799            parsed.destination = Some(PathBuf::from(value));
800            Ok(next_index)
801        }
802        "--api-base-url" => {
803            set_add_string(args, index, flag, inline_value, &mut parsed.api_base_url)
804        }
805        _ => Err(format!("unknown add flag {flag}")),
806    }
807}
808
809fn set_add_string(
810    args: &[OsString],
811    index: usize,
812    flag: &str,
813    inline_value: Option<&str>,
814    slot: &mut Option<String>,
815) -> Result<usize, String> {
816    let (value, next_index) = flag_value(args, index, flag, inline_value, "add")?;
817    *slot = Some(value);
818    Ok(next_index)
819}
820
821fn add_url_plan(parsed: AddParseState) -> Result<AddUrlPlan, String> {
822    if parsed.registry.is_some() {
823        return Err(
824            "runx add <github-url> uses --api-base-url for the hosted index API, not --registry"
825                .to_owned(),
826        );
827    }
828    if parsed.version.is_some() {
829        return Err("runx add <github-url> uses --ref for git refs, not --version".to_owned());
830    }
831    if parsed.expected_digest.is_some() || parsed.destination.is_some() {
832        return Err(
833            "runx add <github-url> indexes the repository and does not support --to or --digest"
834                .to_owned(),
835        );
836    }
837    Ok(AddUrlPlan {
838        repo: parsed.subject.unwrap_or_default(),
839        repo_ref: parsed.repo_ref,
840        api_base_url: parsed.api_base_url,
841        json: parsed.json,
842    })
843}
844
845fn add_registry_plan(parsed: AddParseState) -> Result<RegistryPlan, String> {
846    if parsed.repo_ref.is_some() {
847        return Err(
848            "runx add <skill-ref> uses --version for registry versions, not --ref".to_owned(),
849        );
850    }
851    if parsed.api_base_url.is_some() {
852        return Err("runx add <skill-ref> does not accept --api-base-url".to_owned());
853    }
854    Ok(RegistryPlan {
855        action: RegistryAction::Install,
856        subject: parsed.subject.unwrap_or_default(),
857        registry: parsed.registry,
858        registry_dir: None,
859        version: parsed.version,
860        expected_digest: parsed.expected_digest,
861        destination: parsed.destination,
862        owner: None,
863        profile: None,
864        trust_tier: None,
865        limit: None,
866        upsert: false,
867        json: parsed.json,
868    })
869}
870
871fn is_github_repo_url_like(value: &str) -> bool {
872    let value = value.trim();
873    let Some(path) = value
874        .strip_prefix("https://github.com/")
875        .or_else(|| value.strip_prefix("http://github.com/"))
876        .or_else(|| value.strip_prefix("github.com/"))
877    else {
878        return false;
879    };
880    let mut parts = path.split('/').filter(|part| !part.is_empty());
881    parts.next().is_some() && parts.next().is_some()
882}
883
884fn parse_init_plan(args: &[OsString]) -> Result<InitPlan, String> {
885    let mut global = false;
886    let mut prefetch_official = false;
887    let mut json = false;
888    let mut index = 1;
889
890    while index < args.len() {
891        let token = os_arg(args, index, "init")?;
892        if token == "-g" {
893            global = true;
894            index += 1;
895            continue;
896        }
897        if !token.starts_with("--") {
898            return Err(format!("unexpected init argument {token}"));
899        }
900
901        let (flag, inline_value) = split_flag(token);
902        match flag {
903            "--json" => {
904                if inline_value.is_some() {
905                    return Err("--json does not take a value".to_owned());
906                }
907                json = true;
908                index += 1;
909            }
910            "--global" => {
911                if inline_value.is_some() {
912                    return Err("--global does not take a value".to_owned());
913                }
914                global = true;
915                index += 1;
916            }
917            "--prefetch" | "--prefetch-official" => {
918                if matches!(inline_value, Some("false") | Some("0")) {
919                    prefetch_official = false;
920                    index += 1;
921                    continue;
922                }
923                let (value, next_index) = optional_flag_value(args, index, inline_value, "init")?;
924                prefetch_official = value.is_none_or(|value| truthy(&value));
925                index = next_index;
926            }
927            _ => return Err(format!("unknown init flag {flag}")),
928        }
929    }
930
931    Ok(InitPlan {
932        global,
933        prefetch_official,
934        json,
935    })
936}
937
938fn parse_dev_plan(args: &[OsString]) -> Result<DevPlan, String> {
939    let mut root = None;
940    let mut lane = None;
941    let mut json = false;
942    let mut index = 1;
943
944    while index < args.len() {
945        let token = os_arg(args, index, "dev")?;
946        if !token.starts_with("--") {
947            if root.is_some() {
948                return Err("runx dev accepts at most one root path".to_owned());
949            }
950            root = Some(PathBuf::from(token));
951            index += 1;
952            continue;
953        }
954
955        let (flag, inline_value) = split_flag(token);
956        match flag {
957            "--json" => {
958                if inline_value.is_some() {
959                    return Err("--json does not take a value".to_owned());
960                }
961                json = true;
962                index += 1;
963            }
964            "--lane" => {
965                let (value, next_index) = flag_value(args, index, flag, inline_value, "dev")?;
966                if value.is_empty() {
967                    return Err("--lane must not be empty".to_owned());
968                }
969                lane = Some(value);
970                index = next_index;
971            }
972            _ => return Err(format!("unknown dev flag {flag}")),
973        }
974    }
975
976    Ok(DevPlan { root, lane, json })
977}
978
979fn parse_doctor_plan(args: &[OsString]) -> Result<DoctorPlan, String> {
980    let mut mode = DoctorMode::Workspace;
981    let mut path = None;
982    let mut json = false;
983    let mut index = 1;
984
985    while index < args.len() {
986        let token = os_arg(args, index, "doctor")?;
987        if !token.starts_with('-') {
988            if matches!(token, "authority" | "registry")
989                && path.is_none()
990                && mode == DoctorMode::Workspace
991            {
992                mode = if token == "authority" {
993                    DoctorMode::Authority
994                } else {
995                    DoctorMode::Registry
996                };
997                index += 1;
998                continue;
999            }
1000            if mode != DoctorMode::Workspace {
1001                return Err(format!(
1002                    "runx doctor {} does not accept a path",
1003                    doctor_mode_name(&mode)
1004                ));
1005            }
1006            if path.is_some() {
1007                return Err("runx doctor accepts at most one path".to_owned());
1008            }
1009            path = Some(PathBuf::from(token));
1010            index += 1;
1011            continue;
1012        }
1013
1014        let (flag, inline_value) = split_flag(token);
1015        match flag {
1016            "--json" | "-j" => {
1017                if inline_value.is_some() {
1018                    return Err("--json does not take a value".to_owned());
1019                }
1020                json = true;
1021                index += 1;
1022            }
1023            _ => return Err(format!("unknown doctor flag {flag}")),
1024        }
1025    }
1026
1027    Ok(DoctorPlan { mode, path, json })
1028}
1029
1030fn doctor_mode_name(mode: &DoctorMode) -> &'static str {
1031    match mode {
1032        DoctorMode::Workspace => "workspace",
1033        DoctorMode::Authority => "authority",
1034        DoctorMode::Registry => "registry",
1035    }
1036}
1037
1038fn parse_list_plan(args: &[OsString]) -> Result<ListPlan, String> {
1039    let mut kind = ListKind::All;
1040    let mut filter = FilterMode::All;
1041    let mut json = false;
1042    let mut saw_kind = false;
1043    let mut index = 1;
1044
1045    while index < args.len() {
1046        let token = os_arg(args, index, "list")?;
1047        if !token.starts_with('-') {
1048            if saw_kind {
1049                return Err("runx list accepts at most one kind".to_owned());
1050            }
1051            kind = parse_list_kind(token).ok_or_else(|| {
1052                "runx list kind must be tools, skills, graphs, packets, or overlays".to_owned()
1053            })?;
1054            saw_kind = true;
1055            index += 1;
1056            continue;
1057        }
1058
1059        let (flag, inline_value) = split_flag(token);
1060        if inline_value.is_some() {
1061            return Err(format!("{flag} does not take a value"));
1062        }
1063        let requested = match flag {
1064            "--json" | "-j" => {
1065                json = true;
1066                index += 1;
1067                continue;
1068            }
1069            "--ok-only" | "--okOnly" => FilterMode::OkOnly,
1070            "--invalid-only" | "--invalidOnly" => FilterMode::InvalidOnly,
1071            _ => return Err(format!("unknown list flag {flag}")),
1072        };
1073        if filter != FilterMode::All && filter != requested {
1074            return Err("runx list accepts either --ok-only or --invalid-only".to_owned());
1075        }
1076        filter = requested;
1077        index += 1;
1078    }
1079
1080    Ok(ListPlan { kind, filter, json })
1081}
1082
1083fn parse_list_kind(value: &str) -> Option<ListKind> {
1084    match value {
1085        "tools" => Some(ListKind::Tools),
1086        "skills" => Some(ListKind::Skills),
1087        "graphs" => Some(ListKind::Graphs),
1088        "packets" => Some(ListKind::Packets),
1089        "overlays" => Some(ListKind::Overlays),
1090        _ => None,
1091    }
1092}
1093
1094fn parse_kernel_plan(args: &[OsString]) -> Result<KernelPlan, String> {
1095    let subcommand = os_arg(args, 1, "kernel")?;
1096    if subcommand != "eval" {
1097        return Err(format!("unknown kernel subcommand {subcommand}"));
1098    }
1099    let parsed = parse_json_eval_input(
1100        args,
1101        2,
1102        JsonEvalCommand {
1103            command: "kernel",
1104            subject: "kernel eval",
1105            duplicate_input: "runx kernel eval accepts exactly one --input",
1106            requires_json: "runx kernel eval requires --json",
1107            requires_input: "runx kernel eval requires --input <file|->",
1108        },
1109    )?;
1110    Ok(KernelPlan {
1111        input: parsed.input.into_kernel_source(),
1112        json: true,
1113    })
1114}
1115
1116// rust-style-allow: long-function because this flat argument parser walks the
1117// payment subcommand grammar in a single readable pass; extracting sub-parsers
1118// would obscure which flags belong to which positional verb.
1119fn parse_payment_plan(args: &[OsString]) -> Result<PaymentPlan, String> {
1120    let topic = os_arg(args, 1, "payment")?;
1121    if topic != "admission" {
1122        return Err(format!("unknown payment subcommand {topic}"));
1123    }
1124    let action = os_arg(args, 2, "payment admission")?;
1125    if action != "issue" {
1126        return Err(format!("unknown payment admission subcommand {action}"));
1127    }
1128    let parsed = parse_json_eval_input(
1129        args,
1130        3,
1131        JsonEvalCommand {
1132            command: "payment admission issue",
1133            subject: "payment admission issue",
1134            duplicate_input: "runx payment admission issue accepts exactly one --input",
1135            requires_json: "runx payment admission issue requires --json",
1136            requires_input: "runx payment admission issue requires --input <file|->",
1137        },
1138    )?;
1139    Ok(PaymentPlan {
1140        action: PaymentAction::IssueAdmission(PaymentAdmissionPlan {
1141            input: parsed.input.into_payment_source(),
1142            json: true,
1143        }),
1144    })
1145}
1146
1147fn parse_parser_plan(args: &[OsString]) -> Result<ParserPlan, String> {
1148    let subcommand = os_arg(args, 1, "parser")?;
1149    if subcommand != "eval" {
1150        return Err(format!("unknown parser subcommand {subcommand}"));
1151    }
1152    let parsed = parse_json_eval_input(
1153        args,
1154        2,
1155        JsonEvalCommand {
1156            command: "parser",
1157            subject: "parser eval",
1158            duplicate_input: "runx parser eval accepts exactly one --input",
1159            requires_json: "runx parser eval requires --json",
1160            requires_input: "runx parser eval requires --input <file|->",
1161        },
1162    )?;
1163    Ok(ParserPlan {
1164        input: parsed.input.into_parser_source(),
1165        json: true,
1166    })
1167}
1168
1169struct JsonEvalCommand {
1170    command: &'static str,
1171    subject: &'static str,
1172    duplicate_input: &'static str,
1173    requires_json: &'static str,
1174    requires_input: &'static str,
1175}
1176
1177struct JsonEvalPlan {
1178    input: JsonEvalInput,
1179}
1180
1181enum JsonEvalInput {
1182    Stdin,
1183    Path(PathBuf),
1184}
1185
1186impl JsonEvalInput {
1187    fn into_kernel_source(self) -> KernelInputSource {
1188        match self {
1189            Self::Stdin => KernelInputSource::Stdin,
1190            Self::Path(path) => KernelInputSource::Path(path),
1191        }
1192    }
1193
1194    fn into_payment_source(self) -> PaymentInputSource {
1195        match self {
1196            Self::Stdin => PaymentInputSource::Stdin,
1197            Self::Path(path) => PaymentInputSource::Path(path),
1198        }
1199    }
1200
1201    fn into_parser_source(self) -> ParserInputSource {
1202        match self {
1203            Self::Stdin => ParserInputSource::Stdin,
1204            Self::Path(path) => ParserInputSource::Path(path),
1205        }
1206    }
1207}
1208
1209fn parse_json_eval_input(
1210    args: &[OsString],
1211    mut index: usize,
1212    command: JsonEvalCommand,
1213) -> Result<JsonEvalPlan, String> {
1214    let mut input = None;
1215    let mut json = false;
1216    while index < args.len() {
1217        let token = os_arg(args, index, command.command)?;
1218        if !token.starts_with("--") {
1219            return Err(format!("unexpected {} argument {token}", command.subject));
1220        }
1221
1222        let (flag, inline_value) = split_flag(token);
1223        match flag {
1224            "--json" => {
1225                if inline_value.is_some() {
1226                    return Err("--json does not take a value".to_owned());
1227                }
1228                json = true;
1229                index += 1;
1230            }
1231            "--input" => {
1232                if input.is_some() {
1233                    return Err(command.duplicate_input.to_owned());
1234                }
1235                let (value, next_index) =
1236                    flag_value(args, index, flag, inline_value, command.command)?;
1237                input = Some(if value == "-" {
1238                    JsonEvalInput::Stdin
1239                } else {
1240                    JsonEvalInput::Path(PathBuf::from(value))
1241                });
1242                index = next_index;
1243            }
1244            _ => return Err(format!("unknown {} flag {flag}", command.subject)),
1245        }
1246    }
1247    if !json {
1248        return Err(command.requires_json.to_owned());
1249    }
1250    Ok(JsonEvalPlan {
1251        input: input.ok_or_else(|| command.requires_input.to_owned())?,
1252    })
1253}
1254
1255fn parse_policy_plan(args: &[OsString]) -> Result<PolicyPlan, String> {
1256    let subcommand = os_arg(args, 1, "policy")?;
1257    let action = match subcommand {
1258        "inspect" => PolicyAction::Inspect,
1259        "lint" => PolicyAction::Lint,
1260        _ => return Err(format!("unknown policy subcommand {subcommand}")),
1261    };
1262    let mut json = false;
1263    let mut positionals = Vec::new();
1264    let mut index = 2;
1265
1266    while index < args.len() {
1267        let token = os_arg(args, index, "policy")?;
1268        if !token.starts_with("--") {
1269            positionals.push(PathBuf::from(token));
1270            index += 1;
1271            continue;
1272        }
1273
1274        let (flag, inline_value) = split_flag(token);
1275        match flag {
1276            "--json" => {
1277                if inline_value.is_some() {
1278                    return Err("--json does not take a value".to_owned());
1279                }
1280                json = true;
1281                index += 1;
1282            }
1283            _ => return Err(format!("unknown policy flag {flag}")),
1284        }
1285    }
1286
1287    let [path] = positionals.as_slice() else {
1288        return Err("runx policy inspect|lint requires exactly one policy path".to_owned());
1289    };
1290    Ok(PolicyPlan {
1291        action,
1292        path: path.clone(),
1293        json,
1294    })
1295}
1296
1297fn parse_tool_plan(args: &[OsString]) -> Result<ToolPlan, String> {
1298    let subcommand = os_arg(args, 1, "tool")?;
1299    let action = match subcommand {
1300        "build" => ToolAction::Build,
1301        "search" => ToolAction::Search,
1302        "inspect" => ToolAction::Inspect,
1303        _ => return Err(format!("unknown tool subcommand {subcommand}")),
1304    };
1305    let mut json = false;
1306    let mut all = false;
1307    let mut source = None;
1308    let mut positionals = Vec::new();
1309    let mut index = 2;
1310
1311    while index < args.len() {
1312        let token = os_arg(args, index, "tool")?;
1313        if !token.starts_with("--") {
1314            positionals.push(token.to_owned());
1315            index += 1;
1316            continue;
1317        }
1318
1319        let (flag, inline_value) = split_flag(token);
1320        match flag {
1321            "--json" => {
1322                if inline_value.is_some() {
1323                    return Err("--json does not take a value".to_owned());
1324                }
1325                json = true;
1326                index += 1;
1327            }
1328            "--all" => {
1329                if inline_value.is_some() {
1330                    return Err("--all does not take a value".to_owned());
1331                }
1332                all = true;
1333                index += 1;
1334            }
1335            "--source" => {
1336                let (value, next_index) = flag_value(args, index, flag, inline_value, "tool")?;
1337                source = Some(value);
1338                index = next_index;
1339            }
1340            _ => return Err(format!("unknown tool flag {flag}")),
1341        }
1342    }
1343
1344    match action {
1345        ToolAction::Build => build_tool_plan(positionals, all, source, json),
1346        ToolAction::Search => search_tool_plan(positionals, all, source, json),
1347        ToolAction::Inspect => inspect_tool_plan(positionals, all, source, json),
1348    }
1349}
1350
1351fn build_tool_plan(
1352    positionals: Vec<String>,
1353    all: bool,
1354    source: Option<String>,
1355    json: bool,
1356) -> Result<ToolPlan, String> {
1357    if source.is_some() {
1358        return Err("runx tool build does not accept --source".to_owned());
1359    }
1360    if all && !positionals.is_empty() {
1361        return Err("runx tool build accepts either --all or one tool directory".to_owned());
1362    }
1363    if !all && positionals.len() != 1 {
1364        return Err("runx tool build requires a tool directory or --all".to_owned());
1365    }
1366
1367    Ok(ToolPlan {
1368        action: ToolAction::Build,
1369        path: positionals.first().map(PathBuf::from),
1370        ref_or_query: None,
1371        all,
1372        source: None,
1373        json,
1374    })
1375}
1376
1377fn search_tool_plan(
1378    positionals: Vec<String>,
1379    all: bool,
1380    source: Option<String>,
1381    json: bool,
1382) -> Result<ToolPlan, String> {
1383    if all {
1384        return Err("runx tool search does not accept --all".to_owned());
1385    }
1386    let query = positionals.join(" ");
1387    if query.is_empty() {
1388        return Err("runx tool search requires a query".to_owned());
1389    }
1390
1391    Ok(ToolPlan {
1392        action: ToolAction::Search,
1393        path: None,
1394        ref_or_query: Some(query),
1395        all: false,
1396        source,
1397        json,
1398    })
1399}
1400
1401fn inspect_tool_plan(
1402    positionals: Vec<String>,
1403    all: bool,
1404    source: Option<String>,
1405    json: bool,
1406) -> Result<ToolPlan, String> {
1407    if all {
1408        return Err("runx tool inspect does not accept --all".to_owned());
1409    }
1410    let tool_ref = positionals.join(" ");
1411    if tool_ref.is_empty() {
1412        return Err("runx tool inspect requires a tool reference".to_owned());
1413    }
1414
1415    Ok(ToolPlan {
1416        action: ToolAction::Inspect,
1417        path: None,
1418        ref_or_query: Some(tool_ref),
1419        all: false,
1420        source,
1421        json,
1422    })
1423}
1424
1425fn parse_registry_plan(args: &[OsString]) -> Result<RegistryPlan, String> {
1426    let subcommand = os_arg(args, 1, "registry")?;
1427    let action = parse_registry_action(subcommand)?;
1428    let mut state = RegistryParseState::default();
1429    parse_registry_args(args, &mut state)?;
1430    let subject = registry_subject(&action, subcommand, &mut state.positionals)?;
1431
1432    Ok(RegistryPlan {
1433        action,
1434        subject,
1435        registry: state.registry,
1436        registry_dir: state.registry_dir,
1437        version: state.version,
1438        expected_digest: state.expected_digest,
1439        destination: state.destination,
1440        owner: state.owner,
1441        profile: state.profile,
1442        trust_tier: state.trust_tier,
1443        limit: state.limit,
1444        upsert: state.upsert,
1445        json: state.json,
1446    })
1447}
1448
1449#[derive(Default)]
1450struct RegistryParseState {
1451    json: bool,
1452    upsert: bool,
1453    registry: Option<String>,
1454    registry_dir: Option<PathBuf>,
1455    version: Option<String>,
1456    expected_digest: Option<String>,
1457    destination: Option<PathBuf>,
1458    owner: Option<String>,
1459    profile: Option<PathBuf>,
1460    trust_tier: Option<runx_runtime::registry::TrustTier>,
1461    limit: Option<usize>,
1462    positionals: Vec<String>,
1463}
1464
1465fn parse_registry_action(subcommand: &str) -> Result<RegistryAction, String> {
1466    match subcommand {
1467        "search" => Ok(RegistryAction::Search),
1468        "read" => Ok(RegistryAction::Read),
1469        "resolve" => Ok(RegistryAction::Resolve),
1470        "install" => Ok(RegistryAction::Install),
1471        "publish" => Ok(RegistryAction::Publish),
1472        _ => Err(format!("unknown registry subcommand {subcommand}")),
1473    }
1474}
1475
1476fn parse_registry_args(args: &[OsString], state: &mut RegistryParseState) -> Result<(), String> {
1477    let mut index = 2;
1478    while index < args.len() {
1479        let token = os_arg(args, index, "registry")?;
1480        if !token.starts_with("--") {
1481            state.positionals.push(token.to_owned());
1482            index += 1;
1483            continue;
1484        }
1485
1486        let (flag, inline_value) = split_flag(token);
1487        index = parse_registry_flag(args, index, flag, inline_value, state)?;
1488    }
1489    Ok(())
1490}
1491
1492fn parse_registry_flag(
1493    args: &[OsString],
1494    index: usize,
1495    flag: &str,
1496    inline_value: Option<&str>,
1497    state: &mut RegistryParseState,
1498) -> Result<usize, String> {
1499    match flag {
1500        "--json" => set_registry_bool_flag(flag, inline_value, &mut state.json, index),
1501        "--upsert" => set_registry_bool_flag(flag, inline_value, &mut state.upsert, index),
1502        "--registry" => {
1503            set_registry_string_flag(args, index, flag, inline_value, &mut state.registry)
1504        }
1505        "--registry-dir" => {
1506            set_registry_path_flag(args, index, flag, inline_value, &mut state.registry_dir)
1507        }
1508        "--version" => set_registry_version_flag(args, index, flag, inline_value, state),
1509        "--digest" => {
1510            set_registry_string_flag(args, index, flag, inline_value, &mut state.expected_digest)
1511        }
1512        "--to" => set_registry_path_flag(args, index, flag, inline_value, &mut state.destination),
1513        "--owner" => set_registry_string_flag(args, index, flag, inline_value, &mut state.owner),
1514        "--profile" => set_registry_path_flag(args, index, flag, inline_value, &mut state.profile),
1515        "--trust-tier" => set_registry_trust_tier_flag(args, index, flag, inline_value, state),
1516        "--limit" => set_registry_limit_flag(args, index, flag, inline_value, state),
1517        _ => Err(format!("unknown registry flag {flag}")),
1518    }
1519}
1520
1521fn set_registry_trust_tier_flag(
1522    args: &[OsString],
1523    index: usize,
1524    flag: &str,
1525    inline_value: Option<&str>,
1526    state: &mut RegistryParseState,
1527) -> Result<usize, String> {
1528    if state.trust_tier.is_some() {
1529        return Err(format!("{flag} was provided more than once"));
1530    }
1531    let (value, next_index) = flag_value(args, index, flag, inline_value, "registry")?;
1532    state.trust_tier = Some(parse_registry_trust_tier(&value)?);
1533    Ok(next_index)
1534}
1535
1536fn parse_registry_trust_tier(value: &str) -> Result<runx_runtime::registry::TrustTier, String> {
1537    match value {
1538        "first_party" | "first-party" => Ok(runx_runtime::registry::TrustTier::FirstParty),
1539        "verified" => Ok(runx_runtime::registry::TrustTier::Verified),
1540        "community" => Ok(runx_runtime::registry::TrustTier::Community),
1541        _ => Err(format!(
1542            "invalid registry trust tier {value}; expected first_party, verified, or community"
1543        )),
1544    }
1545}
1546
1547fn set_registry_bool_flag(
1548    flag: &str,
1549    inline_value: Option<&str>,
1550    target: &mut bool,
1551    index: usize,
1552) -> Result<usize, String> {
1553    if inline_value.is_some() {
1554        return Err(format!("{flag} does not take a value"));
1555    }
1556    *target = true;
1557    Ok(index + 1)
1558}
1559
1560fn set_registry_string_flag(
1561    args: &[OsString],
1562    index: usize,
1563    flag: &str,
1564    inline_value: Option<&str>,
1565    target: &mut Option<String>,
1566) -> Result<usize, String> {
1567    let (value, next_index) = flag_value(args, index, flag, inline_value, "registry")?;
1568    *target = Some(value);
1569    Ok(next_index)
1570}
1571
1572fn set_registry_path_flag(
1573    args: &[OsString],
1574    index: usize,
1575    flag: &str,
1576    inline_value: Option<&str>,
1577    target: &mut Option<PathBuf>,
1578) -> Result<usize, String> {
1579    let (value, next_index) = flag_value(args, index, flag, inline_value, "registry")?;
1580    *target = Some(PathBuf::from(value));
1581    Ok(next_index)
1582}
1583
1584fn set_registry_limit_flag(
1585    args: &[OsString],
1586    index: usize,
1587    flag: &str,
1588    inline_value: Option<&str>,
1589    state: &mut RegistryParseState,
1590) -> Result<usize, String> {
1591    let (value, next_index) = flag_value(args, index, flag, inline_value, "registry")?;
1592    let limit = value
1593        .parse::<usize>()
1594        .map_err(|_| "--limit must be a positive integer".to_owned())?;
1595    if limit == 0 {
1596        return Err("--limit must be greater than zero".to_owned());
1597    }
1598    state.limit = Some(limit);
1599    Ok(next_index)
1600}
1601
1602fn set_registry_version_flag(
1603    args: &[OsString],
1604    index: usize,
1605    flag: &str,
1606    inline_value: Option<&str>,
1607    state: &mut RegistryParseState,
1608) -> Result<usize, String> {
1609    let (value, next_index) = flag_value(args, index, flag, inline_value, "registry")?;
1610    validate_registry_version(&value)?;
1611    state.version = Some(value);
1612    Ok(next_index)
1613}
1614
1615fn registry_subject(
1616    action: &RegistryAction,
1617    subcommand: &str,
1618    positionals: &mut Vec<String>,
1619) -> Result<String, String> {
1620    match action {
1621        RegistryAction::Search => {
1622            if positionals.is_empty() {
1623                return Err("runx registry search requires a query".to_owned());
1624            }
1625            let subject = positionals.join(" ");
1626            validate_registry_ref_version(&subject)?;
1627            Ok(subject)
1628        }
1629        RegistryAction::Read | RegistryAction::Resolve | RegistryAction::Install => {
1630            if positionals.len() != 1 {
1631                return Err(format!(
1632                    "runx registry {subcommand} requires exactly one ref"
1633                ));
1634            }
1635            let subject = positionals.remove(0);
1636            validate_registry_ref_version(&subject)?;
1637            Ok(subject)
1638        }
1639        RegistryAction::Publish => {
1640            if positionals.len() != 1 {
1641                return Err(
1642                    "runx registry publish requires exactly one skill markdown path".to_owned(),
1643                );
1644            }
1645            Ok(positionals.remove(0))
1646        }
1647    }
1648}
1649
1650fn validate_registry_ref_version(value: &str) -> Result<(), String> {
1651    if let Some(version) = parse_registry_ref(value).version {
1652        validate_registry_version(&version)?;
1653    }
1654    Ok(())
1655}
1656
1657fn validate_registry_version(value: &str) -> Result<(), String> {
1658    if value.trim().is_empty() {
1659        return Err("registry version must not be empty".to_owned());
1660    }
1661    if value.len() > 128 {
1662        return Err("registry version must be 128 characters or fewer".to_owned());
1663    }
1664    if value.chars().any(|character| {
1665        !character.is_ascii_alphanumeric() && !matches!(character, '.' | '_' | '-' | '+')
1666    }) {
1667        return Err(
1668            "registry version may only contain ASCII letters, numbers, '.', '_', '-', or '+'"
1669                .to_owned(),
1670        );
1671    }
1672    if matches!(value, "." | "..") {
1673        return Err("registry version must not be '.' or '..'".to_owned());
1674    }
1675    Ok(())
1676}
1677
1678fn truthy(value: &str) -> bool {
1679    matches!(value, "true" | "1" | "yes" | "official")
1680}