1use 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#[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 pub fixture_paths: Vec<OsString>,
88 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
172pub 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
615fn 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
1116fn 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}