1use clap::{Parser, Subcommand, ValueEnum};
2use std::ffi::OsString;
3use std::net::IpAddr;
4use std::path::PathBuf;
5
6use crate::benchmark::{BenchmarkCommand, GpuBenchmarkBackend};
7use crate::models;
8use crate::runtime::RuntimeCommand;
9use mesh_llm_events::LogFormat;
10use serde::Serialize;
11
12#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
13pub enum BinaryFlavor {
14 #[default]
15 Cpu,
16 Cuda,
17 Rocm,
18 Vulkan,
19 Metal,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
23pub enum TrustPolicy {
24 #[default]
25 Off,
26 PreferOwned,
27 RequireOwned,
28 Allowlist,
29}
30
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
32pub enum MeshDiscoveryMode {
33 #[default]
34 Nostr,
35 Mdns,
36}
37
38impl MeshDiscoveryMode {
39 pub const fn as_str(self) -> &'static str {
40 match self {
41 Self::Nostr => "nostr",
42 Self::Mdns => "mdns",
43 }
44 }
45
46 pub const fn source(self) -> &'static str {
47 match self {
48 Self::Nostr => "nostr-relay",
49 Self::Mdns => "mdns-sd",
50 }
51 }
52
53 pub const fn scope(self) -> DiscoveryScope {
54 match self {
55 Self::Nostr => DiscoveryScope::Public,
56 Self::Mdns => DiscoveryScope::Lan,
57 }
58 }
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
62#[serde(rename_all = "snake_case")]
63pub enum DiscoveryScope {
64 Public,
65 Lan,
66}
67
68impl DiscoveryScope {
69 pub const fn as_str(self) -> &'static str {
70 match self {
71 Self::Public => "public",
72 Self::Lan => "lan",
73 }
74 }
75}
76
77fn parse_relay_auth_pair(s: &str) -> Result<(String, String), String> {
85 let Some((url, token)) = s.split_once('=') else {
86 return Err("expected URL=TOKEN, no '=' separator found (token redacted)".to_string());
87 };
88 if url.is_empty() {
89 return Err("expected URL=TOKEN, got empty URL (token redacted)".to_string());
90 }
91 if token.is_empty() {
92 return Err(format!(
93 "expected URL=TOKEN, got empty token for URL {url:?}"
94 ));
95 }
96 Ok((url.to_string(), token.to_string()))
97}
98
99#[cfg(test)]
100mod relay_auth_parser_tests {
101 use super::parse_relay_auth_pair;
102
103 #[test]
104 fn parses_simple_pair() {
105 let (url, token) = parse_relay_auth_pair("https://r.example/=abc123").unwrap();
106 assert_eq!(url, "https://r.example/");
107 assert_eq!(token, "abc123");
108 }
109
110 #[test]
111 fn preserves_equals_in_token() {
112 let (_, token) = parse_relay_auth_pair("https://r/=eyJhbGciOiJFZERTQSJ9.payload==")
114 .expect("token with '=' must parse");
115 assert_eq!(token, "eyJhbGciOiJFZERTQSJ9.payload==");
116 }
117
118 #[test]
119 fn rejects_missing_separator() {
120 assert!(parse_relay_auth_pair("no-separator").is_err());
121 }
122
123 #[test]
124 fn rejects_empty_url() {
125 assert!(parse_relay_auth_pair("=token").is_err());
126 }
127
128 #[test]
129 fn rejects_empty_token() {
130 assert!(parse_relay_auth_pair("https://r/=").is_err());
131 }
132
133 #[test]
134 fn parser_errors_never_leak_token_portion() {
135 let secret_token = "super-secret-bearer-token-xyz-12345";
141
142 let err = parse_relay_auth_pair(secret_token).expect_err("should fail");
145 assert!(
146 !err.contains(secret_token),
147 "missing-separator error must not echo the input: {err}"
148 );
149
150 let err = parse_relay_auth_pair(&format!("={secret_token}")).expect_err("should fail");
153 assert!(
154 !err.contains(secret_token),
155 "empty-URL error must not echo the token: {err}"
156 );
157
158 let err = parse_relay_auth_pair("https://r.example/=").expect_err("should fail");
161 assert!(
162 err.contains("https://r.example/"),
163 "empty-token error should name the URL: {err}"
164 );
165 }
166}
167
168#[derive(Subcommand, Debug)]
169pub enum TrustCommand {
170 Add {
172 owner_id: String,
174 #[arg(long)]
176 label: Option<String>,
177 #[arg(long)]
179 trust_store: Option<PathBuf>,
180 },
181 Remove {
183 owner_id: String,
185 #[arg(long)]
187 trust_store: Option<PathBuf>,
188 },
189 List {
191 #[arg(long)]
193 trust_store: Option<PathBuf>,
194 },
195}
196
197#[derive(Subcommand, Debug)]
198pub enum AuthCommand {
199 Init {
201 #[arg(long)]
203 owner_key: Option<PathBuf>,
204 #[arg(long)]
206 force: bool,
207 #[arg(long, conflicts_with = "keychain")]
209 no_passphrase: bool,
210 #[arg(long)]
215 keychain: bool,
216 },
217 Status {
219 #[arg(long)]
221 owner_key: Option<PathBuf>,
222 #[arg(long)]
224 node_key: Option<PathBuf>,
225 #[arg(long)]
227 node_ownership: Option<PathBuf>,
228 #[arg(long)]
230 trust_store: Option<PathBuf>,
231 },
232 SignNode {
234 #[arg(long)]
236 owner_key: Option<PathBuf>,
237 #[arg(long)]
239 node_key: Option<PathBuf>,
240 #[arg(long)]
242 out: Option<PathBuf>,
243 #[arg(long)]
245 hostname_hint: Option<String>,
246 #[arg(long)]
248 node_label: Option<String>,
249 #[arg(long, default_value = "168")]
251 expires_in_hours: u64,
252 },
253 RenewNode {
255 #[arg(long)]
257 owner_key: Option<PathBuf>,
258 #[arg(long)]
260 node_key: Option<PathBuf>,
261 #[arg(long)]
263 out: Option<PathBuf>,
264 #[arg(long)]
266 hostname_hint: Option<String>,
267 #[arg(long)]
269 node_label: Option<String>,
270 #[arg(long, default_value = "168")]
272 expires_in_hours: u64,
273 },
274 VerifyNode {
276 #[arg(long)]
278 file: Option<PathBuf>,
279 #[arg(long)]
281 node_id: Option<String>,
282 #[arg(long)]
284 trust_store: Option<PathBuf>,
285 #[arg(long = "verify-trust-policy", value_enum)]
287 trust_policy: Option<TrustPolicy>,
288 },
289 RotateNode {
291 #[arg(long)]
293 owner_key: Option<PathBuf>,
294 #[arg(long)]
296 node_key: Option<PathBuf>,
297 #[arg(long)]
299 out: Option<PathBuf>,
300 #[arg(long)]
302 hostname_hint: Option<String>,
303 #[arg(long)]
305 node_label: Option<String>,
306 #[arg(long, default_value = "168")]
308 expires_in_hours: u64,
309 #[arg(long)]
311 revoke_current: bool,
312 #[arg(long)]
314 reason: Option<String>,
315 #[arg(long)]
317 trust_store: Option<PathBuf>,
318 },
319 RevokeOwner {
321 owner_id: String,
323 #[arg(long)]
325 reason: Option<String>,
326 #[arg(long)]
328 trust_store: Option<PathBuf>,
329 },
330 RevokeNode {
332 #[arg(long)]
334 cert_id: Option<String>,
335 #[arg(long)]
337 node_id: Option<String>,
338 #[arg(long)]
340 reason: Option<String>,
341 #[arg(long)]
343 trust_store: Option<PathBuf>,
344 },
345 RotateOwner {
347 #[arg(long)]
349 owner_key: Option<PathBuf>,
350 #[arg(long)]
352 no_passphrase: bool,
353 #[arg(long)]
355 force: bool,
356 },
357 Trust {
359 #[command(subcommand)]
360 command: TrustCommand,
361 },
362}
363
364#[derive(Subcommand, Debug)]
365pub enum GpuCommand {
366 Detect {
368 #[arg(long)]
370 json: bool,
371 },
372 #[command(name = "run-benchmark", hide = true)]
374 RunBenchmark {
375 #[arg(long, value_enum)]
376 backend: GpuBenchmarkBackend,
377 },
378}
379
380#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
381pub enum MeshGuardrailCliMode {
382 #[default]
383 Disabled,
384 Metrics,
385 Enforce,
386}
387
388impl MeshGuardrailCliMode {
389 pub const fn as_str(self) -> &'static str {
390 match self {
391 Self::Disabled => "disabled",
392 Self::Metrics => "metrics",
393 Self::Enforce => "enforce",
394 }
395 }
396}
397
398#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
399pub enum SpeculativeNgramProposerCli {
400 Cache,
401 Suffix,
402}
403
404impl SpeculativeNgramProposerCli {
405 pub const fn as_str(self) -> &'static str {
406 match self {
407 Self::Cache => "cache",
408 Self::Suffix => "suffix",
409 }
410 }
411}
412
413#[derive(Parser, Debug)]
414#[command(
415 name = "mesh-llm",
416 version = mesh_llm_build_info::BUILD_VERSION,
417 about = "Pool GPUs over the internet for LLM inference",
418 after_help = "Preferred runtime entrypoints:\n mesh-llm serve\n mesh-llm serve --model Qwen3-8B-Q4_K_M\n mesh-llm client --auto\n mesh-llm gpus\n\n`mesh-llm serve` loads startup models from ~/.mesh-llm/config.toml.\nRun with --help-advanced for all options.\n\nExternal backends (vLLM, TGI, Ollama):\n Install the plugin:\n mesh-llm plugins install openai-endpoint\n Add to ~/.mesh-llm/config.toml:\n [[plugin]]\n name = \"openai-endpoint\"\n url = \"http://gpu-box:8000/v1\"\n Then: mesh-llm serve (or: mesh-llm client for client-only mode)\n\nFlash-MoE SSD backend:\n Install the plugin:\n mesh-llm plugins install flash-moe\n Add [[plugin]] name = \"flash-moe\" with url or plugin-owned args.\n Then: mesh-llm serve (or: mesh-llm client for client-only mode)"
419)]
420pub struct Cli {
421 #[command(subcommand)]
422 pub command: Option<Command>,
423
424 #[arg(long, value_enum, default_value_t = LogFormat::Pretty)]
426 pub log_format: LogFormat,
427
428 #[arg(long)]
430 pub debug: bool,
431
432 #[arg(long, hide = true)]
434 pub skippy_metrics_otlp_grpc: Option<String>,
435
436 #[arg(long = "mesh-guardrails", value_enum, default_value_t = MeshGuardrailCliMode::Disabled)]
438 pub mesh_guardrails: MeshGuardrailCliMode,
439
440 #[arg(long, hide = true)]
442 pub help_advanced: bool,
443
444 #[arg(long, short)]
446 pub join: Vec<String>,
447
448 #[arg(long, default_missing_value = "", num_args = 0..=1)]
450 pub discover: Option<String>,
451
452 #[arg(long)]
454 pub auto: bool,
455
456 #[arg(long, value_enum, default_value_t = MeshDiscoveryMode::Nostr, global = true)]
458 pub mesh_discovery_mode: MeshDiscoveryMode,
459
460 #[arg(long)]
462 pub model: Vec<PathBuf>,
463
464 #[arg(long)]
466 pub gguf: Vec<PathBuf>,
467
468 #[arg(long, hide = true)]
470 pub mmproj: Option<PathBuf>,
471
472 #[arg(long, default_value = "9337")]
474 pub port: u16,
475
476 #[arg(long)]
478 pub local_model_only: bool,
479
480 #[arg(
482 long,
483 hide = true,
484 requires = "local_model_only",
485 requires_all = [
486 "native_serving_plugin_config",
487 "native_serving_plugin_state",
488 "native_serving_plugin_deadline_ms"
489 ]
490 )]
491 pub native_serving_plugin: Option<PathBuf>,
492
493 #[arg(long, hide = true, requires = "native_serving_plugin")]
495 pub native_serving_plugin_config: Option<PathBuf>,
496
497 #[arg(long, hide = true, requires = "native_serving_plugin")]
499 pub native_serving_plugin_state: Option<PathBuf>,
500
501 #[arg(
503 long,
504 hide = true,
505 requires = "native_serving_plugin",
506 value_parser = clap::value_parser!(u64).range(1..)
507 )]
508 pub native_serving_plugin_deadline_ms: Option<u64>,
509
510 #[arg(long)]
512 pub client: bool,
513
514 #[arg(long, default_value = "3131")]
516 pub console: u16,
517
518 #[arg(long)]
520 pub headless: bool,
521
522 #[arg(long)]
524 pub swarm_capture: Option<PathBuf>,
525
526 #[arg(long)]
529 pub publish: bool,
530
531 #[arg(long)]
534 pub mesh_name: Option<String>,
535
536 #[arg(long)]
538 pub region: Option<String>,
539
540 #[arg(long)]
542 pub min_node_version: Option<String>,
543
544 #[arg(long)]
546 pub max_node_version: Option<String>,
547
548 #[arg(long)]
550 pub min_protocol_version: Option<u32>,
551
552 #[arg(long)]
554 pub max_protocol_version: Option<u32>,
555
556 #[arg(long)]
558 pub require_release_attestation: bool,
559
560 #[arg(long = "release-signer-key")]
562 pub release_signer_key: Vec<String>,
563
564 #[arg(long)]
566 pub name: Option<String>,
567
568 #[arg(long, hide = true)]
570 pub plugin: Option<String>,
571
572 #[arg(long, global = true)]
574 pub auto_update: bool,
575
576 #[arg(long, hide = true)]
579 pub speculative_strategy: Option<String>,
580
581 #[arg(long, hide = true)]
583 pub speculative_ngram_min: Option<u32>,
584
585 #[arg(long, hide = true)]
587 pub speculative_ngram_max: Option<u32>,
588
589 #[arg(long, hide = true)]
591 pub speculative_ngram_max_proposal_tokens: Option<u32>,
592
593 #[arg(long, hide = true, value_enum)]
595 pub speculative_ngram_proposer: Option<SpeculativeNgramProposerCli>,
596
597 #[arg(long, hide = true)]
599 pub speculative_extension_max_tokens: Option<u32>,
600
601 #[arg(long, hide = true)]
603 pub speculative_native_mtp_reject_cooldown_tokens: Option<u32>,
604
605 #[arg(long, hide = true)]
607 pub speculative_native_mtp_suppress_cooldown_drafts: bool,
608
609 #[arg(
611 long,
612 hide = true,
613 conflicts_with = "speculative_native_mtp_suppress_cooldown_drafts"
614 )]
615 pub speculative_native_mtp_allow_cooldown_drafts: bool,
616
617 #[arg(long, hide = true)]
619 pub speculative_native_mtp_suppress_cooldown_draft_limit: Option<u32>,
620
621 #[arg(long, hide = true)]
623 pub speculative_verify_window_min_tokens: Option<u32>,
624
625 #[arg(long, hide = true)]
627 pub speculative_verify_window_max_tokens: Option<u32>,
628
629 #[arg(long, hide = true)]
631 pub speculative_verify_window_pipeline_depth: Option<u32>,
632
633 #[arg(long, hide = true)]
635 pub draft: Option<PathBuf>,
636
637 #[arg(long, default_value = "8", hide = true)]
639 pub draft_max: u16,
640
641 #[arg(long, hide = true)]
643 pub no_draft: bool,
644
645 #[arg(long, hide = true)]
647 pub split: bool,
648
649 #[arg(long, value_name = "PATH", requires = "split", hide = true)]
651 pub split_topology_lock: Option<PathBuf>,
652
653 #[arg(long, hide = true)]
655 pub ctx_size: Option<u32>,
656
657 #[arg(long)]
659 pub max_vram: Option<f64>,
660
661 #[arg(long = "no-enumerate-host", hide = true)]
663 pub no_enumerate_host: bool,
664
665 #[arg(long, hide = true)]
667 pub bin_dir: Option<PathBuf>,
668
669 #[arg(long, value_enum)]
671 pub llama_flavor: Option<BinaryFlavor>,
672
673 #[arg(long, hide = true)]
675 pub device: Option<String>,
676
677 #[arg(long, hide = true)]
679 pub tensor_split: Option<String>,
680
681 #[arg(long, hide = true)]
683 pub relay: Vec<String>,
684
685 #[arg(long = "relay-auth", value_parser = parse_relay_auth_pair, hide = true)]
694 pub relay_auth: Vec<(String, String)>,
695
696 #[arg(long = "disable-iroh-relays", hide = true)]
698 pub disable_iroh_relays: bool,
699
700 #[arg(long, hide = true)]
702 pub bind_port: Option<u16>,
703
704 #[arg(long, hide = true)]
706 pub bind_ip: Option<IpAddr>,
707
708 #[arg(long, hide = true)]
710 pub listen_all: bool,
711
712 #[arg(long, hide = true)]
714 pub max_clients: Option<usize>,
715
716 #[arg(long, hide = true)]
718 pub nostr_relay: Vec<String>,
719
720 #[arg(long, hide = true)]
722 pub no_console: bool,
723
724 #[arg(long)]
726 pub config: Option<PathBuf>,
727
728 #[arg(long)]
730 pub owner_key: Option<PathBuf>,
731
732 #[arg(long, hide = true)]
734 pub control_bind: Option<std::net::SocketAddr>,
735
736 #[arg(long, hide = true)]
738 pub control_advertise_addr: Option<std::net::SocketAddr>,
739
740 #[arg(long)]
742 pub owner_required: bool,
743
744 #[arg(long)]
746 pub node_label: Option<String>,
747
748 #[arg(long, value_enum)]
750 pub trust_policy: Option<TrustPolicy>,
751
752 #[arg(long)]
754 pub trust_owner: Vec<String>,
755
756 #[arg(skip)]
758 pub nostr_discovery: bool,
759}
760
761#[derive(Subcommand, Debug)]
762pub enum Command {
763 Models {
765 #[command(subcommand)]
766 command: models::ModelsCommand,
767 },
768 Download {
770 name: Option<String>,
772 #[arg(long)]
774 draft: bool,
775 },
776 Update {
778 #[arg(long)]
780 version: Option<String>,
781 #[arg(long, value_enum, conflicts_with = "detect_flavor")]
783 flavor: Option<BinaryFlavor>,
784 #[arg(long, conflicts_with = "flavor")]
786 detect_flavor: bool,
787 },
788 #[command(alias = "gpu")]
790 Gpus {
791 #[arg(long)]
793 json: bool,
794 #[command(subcommand)]
795 command: Option<GpuCommand>,
796 },
797 Runtime {
799 #[command(subcommand)]
800 command: Option<RuntimeCommand>,
801 },
802 Config {
804 #[command(subcommand)]
805 command: ConfigCommand,
806 },
807 Doctor {
809 #[arg(long)]
811 json: bool,
812 #[command(subcommand)]
813 command: Option<DoctorCommand>,
814 },
815 Setup {
817 #[arg(long)]
819 yes: bool,
820 #[arg(long = "no-interactive")]
822 no_interactive: bool,
823 #[arg(long, conflicts_with = "no_service")]
825 service: bool,
826 #[arg(long = "no-service", conflicts_with = "service")]
828 no_service: bool,
829 #[arg(long = "skip-runtime")]
831 skip_runtime: bool,
832 #[arg(long)]
834 verbose: bool,
835 },
836 Uninstall {
838 #[arg(long)]
840 dry_run: bool,
841 #[arg(long)]
843 yes: bool,
844 #[arg(long)]
846 keep_cache: bool,
847 #[arg(long)]
849 keep_service_files: bool,
850 #[arg(long, conflicts_with = "keep_config")]
852 purge_config: bool,
853 #[arg(long, conflicts_with = "purge_config")]
855 keep_config: bool,
856 #[arg(long)]
858 binary_path: Option<std::path::PathBuf>,
859 #[arg(long)]
861 json: bool,
862 #[arg(long)]
864 verbose: bool,
865 },
866 Load {
868 name: String,
870 #[arg(long, default_value = "3131")]
872 port: u16,
873 },
874 #[command(alias = "drop")]
876 Unload {
877 name: String,
879 #[arg(long, default_value = "3131")]
881 port: u16,
882 },
883 Status {
885 #[arg(long, default_value = "3131")]
887 port: u16,
888 },
889 Discover {
891 #[arg(long)]
893 name: Option<String>,
894 #[arg(long)]
896 model: Option<String>,
897 #[arg(long)]
899 min_vram: Option<f64>,
900 #[arg(long)]
902 region: Option<String>,
903 #[arg(long)]
905 auto: bool,
906 #[arg(long)]
908 relay: Vec<String>,
909 },
910 #[command(hide = true)]
912 RotateKey,
913 #[command(name = "goose")]
917 Goose {
918 #[arg(long)]
920 model: Option<String>,
921 #[arg(long, default_value = "9337")]
923 port: u16,
924 },
925 #[command(name = "claude")]
929 Claude {
930 #[arg(long)]
932 model: Option<String>,
933 #[arg(long, default_value = "9337")]
935 port: u16,
936 },
937 #[command(name = "pi")]
942 Pi {
943 #[arg(long)]
945 model: Option<String>,
946 #[arg(long, default_value = "127.0.0.1:9337")]
948 host: String,
949 #[arg(long)]
951 write: bool,
952 },
953 #[command(name = "opencode")]
957 Opencode {
958 #[arg(long)]
960 model: Option<String>,
961 #[arg(long, default_value = "127.0.0.1:9337")]
963 host: String,
964 #[arg(long)]
966 write: bool,
967 },
968 Stop,
970 #[command(name = "plugins", alias = "plugin")]
972 Plugin {
973 #[command(subcommand)]
974 command: PluginCommand,
975 },
976 Skills {
978 #[command(subcommand)]
979 command: SkillCommand,
980 },
981 Benchmark {
983 #[command(subcommand)]
984 command: BenchmarkCommand,
985 },
986 #[command(name = "model-prepare", hide = true, alias = "model-package")]
993 ModelPrepare {
994 source_repo: Option<String>,
996
997 #[arg(long)]
999 quant: Option<String>,
1000
1001 #[arg(long)]
1003 target: Option<String>,
1004
1005 #[arg(long)]
1007 model_id: Option<String>,
1008
1009 #[arg(long, default_value = "auto")]
1011 flavor: String,
1012
1013 #[arg(long, default_value = "1h")]
1015 timeout: String,
1016
1017 #[arg(long, default_value = "main")]
1019 mesh_llm_ref: String,
1020
1021 #[arg(long)]
1023 dry_run: bool,
1024
1025 #[arg(long)]
1027 confirm: bool,
1028
1029 #[arg(long)]
1031 follow: bool,
1032
1033 #[arg(long)]
1035 json: bool,
1036
1037 #[arg(long)]
1039 status: Option<String>,
1040
1041 #[arg(long)]
1043 logs: Option<String>,
1044
1045 #[arg(long)]
1047 cancel: Option<String>,
1048
1049 #[arg(long)]
1051 list: bool,
1052
1053 #[arg(long)]
1055 update_script: bool,
1056 },
1057 Auth {
1059 #[command(subcommand)]
1060 command: AuthCommand,
1061 },
1062 #[command(external_subcommand)]
1064 ExternalPlugin(Vec<OsString>),
1065}
1066
1067#[derive(Subcommand, Debug)]
1068pub enum ConfigCommand {
1069 Validate {
1071 #[arg(long = "config-path")]
1073 config_path: Option<PathBuf>,
1074 #[arg(long)]
1076 json: bool,
1077 },
1078}
1079
1080#[derive(Subcommand, Debug)]
1081pub enum PluginCommand {
1082 Install {
1084 #[arg(required_unless_present = "archive", conflicts_with = "archive")]
1086 reference: Option<String>,
1087 #[arg(long, value_name = "PATH", requires = "name")]
1089 archive: Option<PathBuf>,
1090 #[arg(long, requires = "archive")]
1092 name: Option<String>,
1093 #[arg(long, requires = "archive")]
1095 version: Option<String>,
1096 },
1097 Update {
1099 name: String,
1101 },
1102 Enable {
1104 name: String,
1106 },
1107 Disable {
1109 name: String,
1111 },
1112 Delete {
1114 name: String,
1116 },
1117 Info {
1119 name: String,
1121 },
1122 Search {
1124 query: Option<String>,
1126 },
1127 List,
1129}
1130
1131#[derive(Subcommand, Debug)]
1132pub enum SkillCommand {
1133 Install {
1135 #[arg(long, value_enum, conflicts_with = "all")]
1137 agent: Vec<SkillAgentArg>,
1138 #[arg(long)]
1140 all: bool,
1141 #[arg(long)]
1143 dry_run: bool,
1144 #[arg(long)]
1146 force: bool,
1147 },
1148}
1149
1150#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
1151pub enum SkillAgentArg {
1152 Global,
1153 Goose,
1154 Pi,
1155 Codex,
1156 Opencode,
1157 Claude,
1158}
1159
1160#[derive(Subcommand, Debug)]
1161pub enum DoctorCommand {
1162 Split {
1164 #[arg(long, visible_alias = "model")]
1166 model_ref: String,
1167 #[arg(long, default_value = "3131")]
1169 port: u16,
1170 #[arg(long)]
1172 json: bool,
1173 #[arg(long)]
1175 output_dir: Option<PathBuf>,
1176 },
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182 use crate::models::{ModelSearchSort, ModelsCommand};
1183 use clap::{CommandFactory, Parser, error::ErrorKind};
1184 use mesh_llm_events::LogFormat;
1185
1186 #[test]
1187 fn native_serving_plugin_deadline_rejects_zero() {
1188 let normalized = crate::parser::normalize_runtime_surface_args([
1189 "mesh-llm",
1190 "serve",
1191 "--local-model-only",
1192 "--native-serving-plugin",
1193 "/tmp/plugin.dylib",
1194 "--native-serving-plugin-config",
1195 "/tmp/plugin.json",
1196 "--native-serving-plugin-state",
1197 "/tmp/plugin-state",
1198 "--native-serving-plugin-deadline-ms",
1199 "0",
1200 ]);
1201 let error = Cli::try_parse_from(normalized.normalized).unwrap_err();
1202 assert_eq!(error.kind(), ErrorKind::ValueValidation);
1203 }
1204
1205 #[test]
1206 fn serve_parses_speculative_decode_overrides() {
1207 let normalized = crate::parser::normalize_runtime_surface_args([
1208 "mesh-llm",
1209 "serve",
1210 "--speculative-strategy",
1211 "mtp-cache",
1212 "--speculative-ngram-min",
1213 "2",
1214 "--speculative-ngram-max",
1215 "6",
1216 "--speculative-extension-max-tokens",
1217 "8",
1218 "--speculative-native-mtp-allow-cooldown-drafts",
1219 "--speculative-verify-window-pipeline-depth",
1220 "3",
1221 ]);
1222 let cli = Cli::try_parse_from(normalized.normalized).expect("clap parse");
1223 assert_eq!(cli.speculative_strategy.as_deref(), Some("mtp-cache"));
1224 assert_eq!(cli.speculative_ngram_min, Some(2));
1225 assert_eq!(cli.speculative_ngram_max, Some(6));
1226 assert_eq!(cli.speculative_extension_max_tokens, Some(8));
1227 assert!(cli.speculative_native_mtp_allow_cooldown_drafts);
1228 assert_eq!(cli.speculative_verify_window_pipeline_depth, Some(3));
1229 }
1230
1231 #[test]
1232 fn serve_parses_standalone_suffix_strategy() {
1233 let normalized = crate::parser::normalize_runtime_surface_args([
1234 "mesh-llm",
1235 "serve",
1236 "--speculative-strategy",
1237 "ngram-suffix",
1238 "--speculative-ngram-proposer",
1239 "suffix",
1240 "--speculative-ngram-min",
1241 "5",
1242 "--speculative-ngram-max",
1243 "32",
1244 ]);
1245 let cli = Cli::try_parse_from(normalized.normalized).expect("clap parse");
1246 assert_eq!(cli.speculative_strategy.as_deref(), Some("ngram-suffix"));
1247 assert_eq!(
1248 cli.speculative_ngram_proposer,
1249 Some(SpeculativeNgramProposerCli::Suffix)
1250 );
1251 assert_eq!(cli.speculative_ngram_min, Some(5));
1252 assert_eq!(cli.speculative_ngram_max, Some(32));
1253 }
1254
1255 #[test]
1256 fn auth_status_accepts_owner_key_locally() {
1257 let cli = Cli::parse_from(["mesh-llm", "auth", "status", "--owner-key", "owner.json"]);
1258
1259 match cli.command.expect("auth command expected") {
1260 Command::Auth {
1261 command: AuthCommand::Status { owner_key, .. },
1262 } => {
1263 assert_eq!(owner_key, Some(PathBuf::from("owner.json")));
1264 }
1265 other => panic!("unexpected command: {other:?}"),
1266 }
1267 }
1268
1269 #[test]
1270 fn auth_status_rejects_runtime_only_owner_required_flag() {
1271 let err = Cli::try_parse_from(["mesh-llm", "auth", "status", "--owner-required"])
1272 .expect_err("runtime-only flag should be rejected for auth status");
1273
1274 let rendered = err.to_string();
1275 assert!(rendered.contains("--owner-required"));
1276 }
1277
1278 #[test]
1279 fn gpu_and_gpus_spellings_are_synonymous() {
1280 let cases = [
1281 (&["gpus"][..], false, None),
1282 (&["gpu"][..], false, None),
1283 (&["gpus", "--json"][..], true, None),
1284 (&["gpu", "--json"][..], true, None),
1285 (&["gpus", "detect"][..], false, Some(false)),
1286 (&["gpu", "detect"][..], false, Some(false)),
1287 (&["gpus", "detect", "--json"][..], false, Some(true)),
1288 (&["gpu", "detect", "--json"][..], false, Some(true)),
1289 ];
1290
1291 for (args, expected_command_json, expected_detect_json) in cases {
1292 assert_gpu_command_parse(args, expected_command_json, expected_detect_json);
1293 }
1294 }
1295
1296 #[test]
1297 fn gpu_tune_is_not_a_gpu_subcommand() {
1298 for spelling in ["gpu", "gpus"] {
1299 let err = Cli::try_parse_from(["mesh-llm", spelling, "tune"])
1300 .expect_err("tune should live under benchmark, not gpu/gpus");
1301
1302 let rendered = err.to_string();
1303 assert!(rendered.contains("tune"), "unexpected error: {rendered}");
1304 }
1305 }
1306
1307 #[test]
1308 fn benchmark_tune_parses_model_trial_options() {
1309 let cli = Cli::parse_from([
1310 "mesh-llm",
1311 "benchmark",
1312 "tune",
1313 "--model",
1314 "qwen.gguf",
1315 "--ctx-sizes",
1316 "4096,8192",
1317 "--batch-sizes",
1318 "1024,2048",
1319 "--ubatch-sizes",
1320 "256,512",
1321 "--mmap-values",
1322 "auto,true,false",
1323 "--mlock-values",
1324 "true,false",
1325 "--speculative-types",
1326 "mtp,draft,mtp-ngram,disabled",
1327 "--spec-draft-models",
1328 "/models/qwen-draft.gguf",
1329 "--spec-draft-max-tokens",
1330 "4,8",
1331 "--spec-draft-min-tokens",
1332 "1,2",
1333 "--spec-ngram-min",
1334 "2,3",
1335 "--spec-ngram-max",
1336 "3,4",
1337 "--throughput-tolerance-pct",
1338 "2.5",
1339 "--max-tokens",
1340 "64",
1341 "--startup-timeout-secs",
1342 "30",
1343 "--request-timeout-secs",
1344 "45",
1345 "--debug-telemetry",
1346 "--apply",
1347 "--replace-existing",
1348 "--launch-args",
1349 "--prompt",
1350 "hello",
1351 "--json",
1352 ]);
1353
1354 let Some(Command::Benchmark {
1355 command: BenchmarkCommand::Tune(tune),
1356 }) = cli.command
1357 else {
1358 panic!("expected benchmark tune command");
1359 };
1360 assert_benchmark_tune_core_options(&tune);
1361 assert_benchmark_tune_speculative_options(&tune);
1362 }
1363
1364 fn assert_benchmark_tune_core_options(tune: &crate::benchmark::BenchmarkTuneCommand) {
1365 assert_eq!(tune.model.as_deref(), Some("qwen.gguf"));
1366 assert!(tune.models.is_empty());
1367 assert!(tune.json);
1368 assert_eq!(tune.ctx_sizes, vec![4096, 8192]);
1369 assert_eq!(tune.batch_sizes, vec![1024, 2048]);
1370 assert_eq!(tune.ubatch_sizes, vec![256, 512]);
1371 assert!(tune.apply);
1372 assert!(tune.replace_existing);
1373 assert!(tune.launch_args);
1374 assert_eq!(
1375 tune.mmap_values,
1376 vec![
1377 crate::benchmark::BenchmarkBoolOrAuto::Auto,
1378 crate::benchmark::BenchmarkBoolOrAuto::Enabled,
1379 crate::benchmark::BenchmarkBoolOrAuto::Disabled,
1380 ]
1381 );
1382 assert_eq!(
1383 tune.mlock_values,
1384 vec![
1385 crate::benchmark::BenchmarkBool::Enabled,
1386 crate::benchmark::BenchmarkBool::Disabled,
1387 ]
1388 );
1389 assert_eq!(tune.throughput_tolerance_pct, 2.5);
1390 assert_eq!(tune.max_tokens, 64);
1391 assert_eq!(tune.startup_timeout_secs, 30);
1392 assert_eq!(tune.request_timeout_secs, 45);
1393 assert!(tune.debug_telemetry);
1394 assert_eq!(tune.prompt, "hello");
1395 }
1396
1397 fn assert_benchmark_tune_speculative_options(tune: &crate::benchmark::BenchmarkTuneCommand) {
1398 assert_eq!(
1399 tune.speculative_types,
1400 vec![
1401 crate::benchmark::BenchmarkSpeculativeType::Mtp,
1402 crate::benchmark::BenchmarkSpeculativeType::Draft,
1403 crate::benchmark::BenchmarkSpeculativeType::MtpNgram,
1404 crate::benchmark::BenchmarkSpeculativeType::Disabled,
1405 ]
1406 );
1407 assert!(!tune.no_speculative_tune);
1408 assert_eq!(
1409 tune.spec_draft_models,
1410 vec![std::path::PathBuf::from("/models/qwen-draft.gguf")]
1411 );
1412 assert_eq!(tune.spec_draft_max_tokens, vec![4, 8]);
1413 assert_eq!(tune.spec_draft_min_tokens, vec![1, 2]);
1414 assert_eq!(tune.spec_ngram_min, vec![2, 3]);
1415 assert_eq!(tune.spec_ngram_max, vec![3, 4]);
1416 }
1417
1418 #[test]
1419 fn benchmark_tune_rejects_conflicting_model_selectors() {
1420 let err = Cli::try_parse_from([
1421 "mesh-llm",
1422 "benchmark",
1423 "tune",
1424 "--model",
1425 "one.gguf",
1426 "--models",
1427 "two.gguf,three.gguf",
1428 ])
1429 .expect_err("conflicting benchmark tune model selectors should be rejected");
1430
1431 let rendered = err.to_string();
1432 assert!(rendered.contains("--model"));
1433 assert!(rendered.contains("--models"));
1434 }
1435
1436 #[test]
1437 fn benchmark_tune_no_speculative_tune_conflicts_with_explicit_speculative_types() {
1438 for (flag, value) in [
1439 ("--speculative-types", "draft"),
1440 ("--spec-draft-models", "/models/draft.gguf"),
1441 ("--spec-draft-max-tokens", "8"),
1442 ("--spec-draft-min-tokens", "2"),
1443 ("--spec-ngram-min", "2"),
1444 ("--spec-ngram-max", "4"),
1445 ] {
1446 let err = Cli::try_parse_from([
1447 "mesh-llm",
1448 "benchmark",
1449 "tune",
1450 "--model",
1451 "qwen.gguf",
1452 "--no-speculative-tune",
1453 flag,
1454 value,
1455 ])
1456 .expect_err("conflicting speculative tune controls should be rejected");
1457
1458 let rendered = err.to_string();
1459 assert!(rendered.contains("--no-speculative-tune"));
1460 assert!(rendered.contains(flag));
1461 }
1462 }
1463
1464 #[test]
1465 fn benchmark_tune_defaults_to_broad_throughput_tolerance() {
1466 let cli = Cli::parse_from(["mesh-llm", "benchmark", "tune", "--model", "qwen.gguf"]);
1467
1468 let Some(Command::Benchmark {
1469 command: BenchmarkCommand::Tune(tune),
1470 }) = cli.command
1471 else {
1472 panic!("expected benchmark tune command");
1473 };
1474 let throughput_tolerance_pct = tune.throughput_tolerance_pct;
1475 assert!(!tune.apply, "apply should be off by default");
1476 assert!(
1477 !tune.replace_existing,
1478 "replace-existing should be off by default"
1479 );
1480 assert!(!tune.launch_args, "launch-args should be off by default");
1481
1482 assert_eq!(throughput_tolerance_pct, 10.0);
1483 }
1484
1485 #[test]
1486 fn benchmark_tune_replace_existing_requires_apply() {
1487 let err = Cli::try_parse_from([
1488 "mesh-llm",
1489 "benchmark",
1490 "tune",
1491 "--model",
1492 "qwen.gguf",
1493 "--replace-existing",
1494 ])
1495 .expect_err("replace-existing should require apply");
1496
1497 let rendered = err.to_string();
1498 assert!(rendered.contains("--apply"), "unexpected error: {rendered}");
1499 }
1500
1501 #[test]
1502 fn hidden_gpu_run_benchmark_parses_backend() {
1503 let cli = Cli::parse_from(["mesh-llm", "gpus", "run-benchmark", "--backend", "cuda"]);
1504
1505 let Some(Command::Gpus {
1506 command: Some(GpuCommand::RunBenchmark { backend }),
1507 ..
1508 }) = cli.command
1509 else {
1510 panic!("expected hidden gpu run-benchmark command");
1511 };
1512
1513 assert_eq!(backend, GpuBenchmarkBackend::Cuda);
1514 }
1515
1516 fn assert_gpu_command_parse(
1517 args: &[&str],
1518 expected_command_json: bool,
1519 expected_detect_json: Option<bool>,
1520 ) {
1521 let cli = Cli::parse_from(std::iter::once("mesh-llm").chain(args.iter().copied()));
1522
1523 match cli.command.expect("gpu command expected") {
1524 Command::Gpus { json, command } => {
1525 assert_eq!(json, expected_command_json, "command json for {args:?}");
1526 match (command, expected_detect_json) {
1527 (None, None) => {}
1528 (Some(GpuCommand::Detect { json }), Some(expected_json)) => {
1529 assert_eq!(json, expected_json, "detect json for {args:?}");
1530 }
1531 (actual, expected) => {
1532 panic!(
1533 "unexpected detect command for {args:?}: {actual:?}, expected {expected:?}"
1534 );
1535 }
1536 }
1537 }
1538 other => panic!("unexpected command for {args:?}: {other:?}"),
1539 }
1540 }
1541
1542 #[test]
1543 fn config_validate_command_parses_config_path_and_json() {
1544 let cli = Cli::parse_from([
1545 "mesh-llm",
1546 "config",
1547 "validate",
1548 "--config-path",
1549 "mesh.toml",
1550 "--json",
1551 ]);
1552
1553 let Some(Command::Config {
1554 command: ConfigCommand::Validate { config_path, json },
1555 }) = cli.command
1556 else {
1557 panic!("expected config validate command");
1558 };
1559 assert_eq!(config_path, Some(PathBuf::from("mesh.toml")));
1560 assert!(json);
1561 }
1562
1563 #[test]
1564 fn help_text_mentions_headless_keeps_management_api() {
1565 let help = Cli::command().render_help().to_string();
1566 assert!(
1567 help.contains("headless") || help.contains("management API"),
1568 "help text should mention headless or management API"
1569 );
1570 }
1571
1572 #[test]
1573 fn opencode_command_accepts_host_flag() {
1574 let cli = Cli::parse_from([
1575 "mesh-llm",
1576 "opencode",
1577 "--host",
1578 "https://mesh.example.com:9443",
1579 ]);
1580
1581 match cli.command.expect("opencode command expected") {
1582 Command::Opencode { model, host, write } => {
1583 assert_eq!(model, None);
1584 assert_eq!(host, "https://mesh.example.com:9443");
1585 assert!(!write);
1586 }
1587 other => panic!("unexpected command: {other:?}"),
1588 }
1589 }
1590
1591 #[test]
1592 fn opencode_command_rejects_port_flag() {
1593 let err = Cli::try_parse_from(["mesh-llm", "opencode", "--port", "9337"])
1594 .expect_err("opencode should reject --port");
1595
1596 let rendered = err.to_string();
1597 assert!(rendered.contains("--port"));
1598 }
1599
1600 #[test]
1601 fn skills_install_accepts_global_agent_target() {
1602 let cli = Cli::parse_from(["mesh-llm", "skills", "install", "--agent", "global"]);
1603
1604 match cli.command.expect("skills command expected") {
1605 Command::Skills {
1606 command:
1607 SkillCommand::Install {
1608 agent, all: false, ..
1609 },
1610 } => {
1611 assert_eq!(agent, vec![SkillAgentArg::Global]);
1612 }
1613 other => panic!("unexpected command: {other:?}"),
1614 }
1615 }
1616
1617 #[test]
1618 fn plugins_install_accepts_local_archive_options() {
1619 let cli = Cli::parse_from([
1620 "mesh-llm",
1621 "plugins",
1622 "install",
1623 "--archive",
1624 "/tmp/demo.tar.gz",
1625 "--name",
1626 "demo",
1627 "--version",
1628 "0.1.0",
1629 ]);
1630
1631 match cli.command.expect("plugins command") {
1632 Command::Plugin {
1633 command:
1634 PluginCommand::Install {
1635 reference: None,
1636 archive: Some(archive),
1637 name: Some(name),
1638 version: Some(version),
1639 },
1640 } => {
1641 assert_eq!(archive, PathBuf::from("/tmp/demo.tar.gz"));
1642 assert_eq!(name, "demo");
1643 assert_eq!(version, "0.1.0");
1644 }
1645 other => panic!("unexpected command: {other:?}"),
1646 }
1647 }
1648
1649 #[test]
1650 fn plugins_install_rejects_reference_with_local_archive() {
1651 let error = Cli::try_parse_from([
1652 "mesh-llm",
1653 "plugins",
1654 "install",
1655 "demo",
1656 "--archive",
1657 "/tmp/demo.tar.gz",
1658 "--name",
1659 "demo",
1660 ])
1661 .expect_err("reference and local archive must conflict");
1662
1663 assert!(error.to_string().contains("cannot be used with"));
1664 }
1665
1666 #[test]
1667 fn cli_rejects_invalid_log_format_values() {
1668 let err = Cli::try_parse_from(["mesh-llm", "--log-format", "invalid"])
1669 .expect_err("invalid log format should be rejected");
1670
1671 assert_eq!(err.kind(), ErrorKind::InvalidValue);
1672 let rendered = err.to_string();
1673 assert!(rendered.contains("--log-format <LOG_FORMAT>"));
1674 assert!(rendered.contains("pretty"));
1675 assert!(rendered.contains("json"));
1676 }
1677
1678 #[test]
1679 fn cli_help_documents_log_format_flag() {
1680 let mut command = Cli::command();
1681 let help = command.render_long_help().to_string();
1682
1683 assert!(help.contains("--log-format <LOG_FORMAT>"));
1684 assert!(help.contains("Terminal output format for app-owned runtime events"));
1685 assert!(help.contains("[default: pretty]"));
1686 assert!(help.contains("[possible values: pretty, json]"));
1687 }
1688
1689 #[test]
1690 fn cli_log_format_selection_is_independent_across_runs() {
1691 let pretty = Cli::parse_from(["mesh-llm", "--log-format", "pretty"]);
1692 assert_eq!(pretty.log_format, LogFormat::Pretty);
1693
1694 let json = Cli::parse_from(["mesh-llm", "--log-format", "json"]);
1695 assert_eq!(json.log_format, LogFormat::Json);
1696
1697 let pretty_again = Cli::parse_from(["mesh-llm", "--log-format", "pretty"]);
1698 assert_eq!(pretty_again.log_format, LogFormat::Pretty);
1699
1700 let json_again = Cli::parse_from(["mesh-llm", "--log-format", "json"]);
1701 assert_eq!(json_again.log_format, LogFormat::Json);
1702 }
1703
1704 #[test]
1705 fn models_search_accepts_canonical_parameter_sort_names() {
1706 let cli = Cli::parse_from([
1707 "mesh-llm",
1708 "models",
1709 "search",
1710 "qwen",
1711 "--sort",
1712 "parameters-desc",
1713 ]);
1714
1715 match cli.command.expect("models command expected") {
1716 Command::Models {
1717 command:
1718 ModelsCommand::Search {
1719 sort: ModelSearchSort::ParametersDesc,
1720 ..
1721 },
1722 } => {}
1723 other => panic!("unexpected command: {other:?}"),
1724 }
1725 }
1726
1727 #[test]
1728 fn models_search_keeps_legacy_parameter_sort_aliases_parsing() {
1729 let cli = Cli::parse_from([
1730 "mesh-llm",
1731 "models",
1732 "search",
1733 "qwen",
1734 "--sort",
1735 "most-parameters",
1736 ]);
1737
1738 match cli.command.expect("models command expected") {
1739 Command::Models {
1740 command:
1741 ModelsCommand::Search {
1742 sort: ModelSearchSort::ParametersDesc,
1743 ..
1744 },
1745 } => {}
1746 other => panic!("unexpected command: {other:?}"),
1747 }
1748 }
1749
1750 #[test]
1751 fn models_certify_parses_package_gate_options() {
1752 let cli = Cli::parse_from([
1753 "mesh-llm",
1754 "models",
1755 "certify",
1756 "hf://meshllm/demo-layers@abc123",
1757 "--package-only",
1758 "--report-out",
1759 "/tmp/cert.json",
1760 "--json",
1761 "--prompt",
1762 "Say ok.",
1763 "--max-tokens",
1764 "2",
1765 ]);
1766
1767 match cli.command.expect("models command expected") {
1768 Command::Models {
1769 command:
1770 ModelsCommand::Certify {
1771 model,
1772 package_only: true,
1773 json: true,
1774 report_out: Some(report_out),
1775 prompt,
1776 max_tokens: 2,
1777 ..
1778 },
1779 } => {
1780 assert_eq!(model, "hf://meshllm/demo-layers@abc123");
1781 assert_eq!(report_out, std::path::PathBuf::from("/tmp/cert.json"));
1782 assert_eq!(prompt, "Say ok.");
1783 }
1784 other => panic!("unexpected command: {other:?}"),
1785 }
1786 }
1787}