Skip to main content

mesh_llm_cli/parser/
commands.rs

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
77/// Parse a `URL=TOKEN` pair for `--relay-auth`. Splits on the first `=` only,
78/// so tokens may contain `=` (base64 padding, JWTs).
79///
80/// Error messages must never include the token portion of the input —
81/// `--relay-auth` carries bearer credentials, and a parse failure could
82/// otherwise leak them into terminal output, logs, and bug reports. The URL
83/// is safe to echo back (it's the public identity of the relay).
84fn 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        // Base64-padded tokens and NIP-98-style payloads often contain `=`.
113        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        // --relay-auth carries bearer credentials; if parsing fails, the
136        // token portion of the input must never appear in the error
137        // message (which lands in terminal output, logs, and bug reports).
138        // The URL is safe to echo back — it's the public identity of the
139        // relay — but everything after the first `=` is secret.
140        let secret_token = "super-secret-bearer-token-xyz-12345";
141
142        // Case 1: no `=` separator. Whole input is treated as a malformed
143        // URL-or-token blob; we cannot tell which it is, so redact both.
144        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        // Case 2: empty URL (`=token`). URL is empty, the token portion is
151        // the secret — must not appear.
152        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        // Case 3: empty token (`URL=`). Token is empty, no secret to leak;
159        // the URL is fine to include and helps the user diagnose.
160        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 an owner to the local trust store allowlist.
171    Add {
172        /// Owner ID to trust.
173        owner_id: String,
174        /// Optional human label for this owner.
175        #[arg(long)]
176        label: Option<String>,
177        /// Path to the trust store file.
178        #[arg(long)]
179        trust_store: Option<PathBuf>,
180    },
181    /// Remove an owner from the local trust store allowlist.
182    Remove {
183        /// Owner ID to remove.
184        owner_id: String,
185        /// Path to the trust store file.
186        #[arg(long)]
187        trust_store: Option<PathBuf>,
188    },
189    /// Show the current trust store contents.
190    List {
191        /// Path to the trust store file.
192        #[arg(long)]
193        trust_store: Option<PathBuf>,
194    },
195}
196
197#[derive(Subcommand, Debug)]
198pub enum AuthCommand {
199    /// Generate a new owner keypair and save to keystore.
200    Init {
201        /// Path to the owner keystore.
202        #[arg(long)]
203        owner_key: Option<PathBuf>,
204        /// Overwrite an existing keystore.
205        #[arg(long)]
206        force: bool,
207        /// Skip passphrase prompt (store keys unencrypted).
208        #[arg(long, conflicts_with = "keychain")]
209        no_passphrase: bool,
210        /// Store a random unlock passphrase in the OS keychain (macOS Keychain,
211        /// Windows Credential Manager, Linux Secret Service). New keystores
212        /// already default to this when a backend is available; use this flag
213        /// to force it when overwriting an existing keystore.
214        #[arg(long)]
215        keychain: bool,
216    },
217    /// Show current owner identity status.
218    Status {
219        /// Path to the owner keystore.
220        #[arg(long)]
221        owner_key: Option<PathBuf>,
222        /// Path to the node identity file (default: ~/.mesh-llm/key).
223        #[arg(long)]
224        node_key: Option<PathBuf>,
225        /// Path to the node ownership certificate.
226        #[arg(long)]
227        node_ownership: Option<PathBuf>,
228        /// Path to the trust store file.
229        #[arg(long)]
230        trust_store: Option<PathBuf>,
231    },
232    /// Sign the current node identity with the existing owner keystore.
233    SignNode {
234        /// Path to the owner keystore.
235        #[arg(long)]
236        owner_key: Option<PathBuf>,
237        /// Path to the node identity file (default: ~/.mesh-llm/key).
238        #[arg(long)]
239        node_key: Option<PathBuf>,
240        /// Output path for the signed node certificate.
241        #[arg(long)]
242        out: Option<PathBuf>,
243        /// Optional hostname hint attached to the certificate.
244        #[arg(long)]
245        hostname_hint: Option<String>,
246        /// Optional human label attached to this node certificate.
247        #[arg(long)]
248        node_label: Option<String>,
249        /// Certificate lifetime in hours.
250        #[arg(long, default_value = "168")]
251        expires_in_hours: u64,
252    },
253    /// Renew the local node ownership certificate in place.
254    RenewNode {
255        /// Path to the owner keystore.
256        #[arg(long)]
257        owner_key: Option<PathBuf>,
258        /// Path to the node identity file (default: ~/.mesh-llm/key).
259        #[arg(long)]
260        node_key: Option<PathBuf>,
261        /// Output path for the signed node certificate.
262        #[arg(long)]
263        out: Option<PathBuf>,
264        /// Optional hostname hint attached to the certificate.
265        #[arg(long)]
266        hostname_hint: Option<String>,
267        /// Optional human label attached to this node certificate.
268        #[arg(long)]
269        node_label: Option<String>,
270        /// Certificate lifetime in hours.
271        #[arg(long, default_value = "168")]
272        expires_in_hours: u64,
273    },
274    /// Verify a node ownership certificate.
275    VerifyNode {
276        /// Path to the signed node certificate.
277        #[arg(long)]
278        file: Option<PathBuf>,
279        /// Override the node ID to verify against.
280        #[arg(long)]
281        node_id: Option<String>,
282        /// Path to the trust store file.
283        #[arg(long)]
284        trust_store: Option<PathBuf>,
285        /// Override trust policy used for verification.
286        #[arg(long = "verify-trust-policy", value_enum)]
287        trust_policy: Option<TrustPolicy>,
288    },
289    /// Rotate the local node identity key.
290    RotateNode {
291        /// Path to the owner keystore.
292        #[arg(long)]
293        owner_key: Option<PathBuf>,
294        /// Path to the node identity file (default: ~/.mesh-llm/key).
295        #[arg(long)]
296        node_key: Option<PathBuf>,
297        /// Output path for the signed node certificate.
298        #[arg(long)]
299        out: Option<PathBuf>,
300        /// Optional hostname hint attached to the certificate.
301        #[arg(long)]
302        hostname_hint: Option<String>,
303        /// Optional human label attached to this node certificate.
304        #[arg(long)]
305        node_label: Option<String>,
306        /// Certificate lifetime in hours.
307        #[arg(long, default_value = "168")]
308        expires_in_hours: u64,
309        /// Revoke the current certificate and node ID in the local trust store first.
310        #[arg(long)]
311        revoke_current: bool,
312        /// Optional revocation reason stored in the trust store.
313        #[arg(long)]
314        reason: Option<String>,
315        /// Path to the trust store file.
316        #[arg(long)]
317        trust_store: Option<PathBuf>,
318    },
319    /// Revoke an owner in the local trust store.
320    RevokeOwner {
321        /// Owner ID to revoke.
322        owner_id: String,
323        /// Optional reason stored in the trust store.
324        #[arg(long)]
325        reason: Option<String>,
326        /// Path to the trust store file.
327        #[arg(long)]
328        trust_store: Option<PathBuf>,
329    },
330    /// Revoke a node certificate or node ID in the local trust store.
331    RevokeNode {
332        /// Certificate ID to revoke.
333        #[arg(long)]
334        cert_id: Option<String>,
335        /// Node endpoint ID to revoke.
336        #[arg(long)]
337        node_id: Option<String>,
338        /// Optional reason stored in the trust store.
339        #[arg(long)]
340        reason: Option<String>,
341        /// Path to the trust store file.
342        #[arg(long)]
343        trust_store: Option<PathBuf>,
344    },
345    /// Rotate the existing owner keystore identity.
346    RotateOwner {
347        /// Path to the owner keystore.
348        #[arg(long)]
349        owner_key: Option<PathBuf>,
350        /// Skip passphrase prompt (store keys unencrypted).
351        #[arg(long)]
352        no_passphrase: bool,
353        /// Overwrite an existing backup file if present.
354        #[arg(long)]
355        force: bool,
356    },
357    /// Manage the local trust store.
358    Trust {
359        #[command(subcommand)]
360        command: TrustCommand,
361    },
362}
363
364#[derive(Subcommand, Debug)]
365pub enum GpuCommand {
366    /// Detect and benchmark local GPUs, rewriting the cached fingerprint.
367    Detect {
368        /// Print machine-readable JSON output.
369        #[arg(long)]
370        json: bool,
371    },
372    /// Run one backend benchmark probe and print raw JSON output.
373    #[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    /// Terminal output format for app-owned runtime events.
425    #[arg(long, value_enum, default_value_t = LogFormat::Pretty)]
426    pub log_format: LogFormat,
427
428    /// Enable mesh runtime debug output; set MESH_LLM_DEBUG_NATIVE_VERBOSE=1 for verbose llama.cpp native logs.
429    #[arg(long)]
430    pub debug: bool,
431
432    /// OTLP/gRPC endpoint for embedded Skippy debug telemetry, for example http://127.0.0.1:14317.
433    #[arg(long, hide = true)]
434    pub skippy_metrics_otlp_grpc: Option<String>,
435
436    /// Server-side mesh guardrail mode for hosted Skippy backends.
437    #[arg(long = "mesh-guardrails", value_enum, default_value_t = MeshGuardrailCliMode::Disabled)]
438    pub mesh_guardrails: MeshGuardrailCliMode,
439
440    /// Show all options (including advanced/niche ones).
441    #[arg(long, hide = true)]
442    pub help_advanced: bool,
443
444    /// Join a mesh via invite token (can repeat).
445    #[arg(long, short)]
446    pub join: Vec<String>,
447
448    /// Discover a mesh and join it.
449    #[arg(long, default_missing_value = "", num_args = 0..=1)]
450    pub discover: Option<String>,
451
452    /// Auto-join the best mesh found via discovery.
453    #[arg(long)]
454    pub auto: bool,
455
456    /// Discovery provider for --auto, --discover, --publish, and the discover command.
457    #[arg(long, value_enum, default_value_t = MeshDiscoveryMode::Nostr, global = true)]
458    pub mesh_discovery_mode: MeshDiscoveryMode,
459
460    /// Model to serve (path, remote catalog name, or Hugging Face ref).
461    #[arg(long)]
462    pub model: Vec<PathBuf>,
463
464    /// Raw local GGUF file to serve directly (repeatable).
465    #[arg(long)]
466    pub gguf: Vec<PathBuf>,
467
468    /// Explicit mmproj sidecar for the primary served model.
469    #[arg(long, hide = true)]
470    pub mmproj: Option<PathBuf>,
471
472    /// API port (default: 9337).
473    #[arg(long, default_value = "9337")]
474    pub port: u16,
475
476    /// Serve one model directly through the local OpenAI API without starting a mesh node.
477    #[arg(long)]
478    pub local_model_only: bool,
479
480    /// Load a native serving plugin into the dedicated local-model path.
481    #[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    /// Absolute path to the native serving plugin configuration.
494    #[arg(long, hide = true, requires = "native_serving_plugin")]
495    pub native_serving_plugin_config: Option<PathBuf>,
496
497    /// Absolute path to the native serving plugin state directory.
498    #[arg(long, hide = true, requires = "native_serving_plugin")]
499    pub native_serving_plugin_state: Option<PathBuf>,
500
501    /// Mesh-enforced hard proposal deadline in milliseconds.
502    #[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    /// Run as a client — no GPU, no model needed.
511    #[arg(long)]
512    pub client: bool,
513
514    /// Web console port (default: 3131).
515    #[arg(long, default_value = "3131")]
516    pub console: u16,
517
518    /// Disable the embedded web UI but keep the management API on the --console port.
519    #[arg(long)]
520    pub headless: bool,
521
522    /// Write passive swarm debug capture JSONL to this local directory (opt-in, no telemetry egress).
523    #[arg(long)]
524    pub swarm_capture: Option<PathBuf>,
525
526    /// Publish this mesh for discovery by other nodes.
527    /// Without this flag, your mesh is private and only joinable via invite token.
528    #[arg(long)]
529    pub publish: bool,
530
531    /// Human-readable name for this mesh (shown in discovery when combined with --publish).
532    /// Naming a mesh does NOT make it publicly discoverable — use --publish for that.
533    #[arg(long)]
534    pub mesh_name: Option<String>,
535
536    /// Region tag, e.g. "US", "EU", "AU" (shown in discovery).
537    #[arg(long)]
538    pub region: Option<String>,
539
540    /// Minimum mesh-llm node version required when creating a new mesh.
541    #[arg(long)]
542    pub min_node_version: Option<String>,
543
544    /// Maximum mesh-llm node version allowed when creating a new mesh.
545    #[arg(long)]
546    pub max_node_version: Option<String>,
547
548    /// Minimum protocol generation required when creating a new mesh.
549    #[arg(long)]
550    pub min_protocol_version: Option<u32>,
551
552    /// Maximum protocol generation allowed when creating a new mesh.
553    #[arg(long)]
554    pub max_protocol_version: Option<u32>,
555
556    /// Require release attestation when creating a new mesh.
557    #[arg(long)]
558    pub require_release_attestation: bool,
559
560    /// Allowed release signer key for mesh creation-time attestation policy (repeatable).
561    #[arg(long = "release-signer-key")]
562    pub release_signer_key: Vec<String>,
563
564    /// Display name for this node.
565    #[arg(long)]
566    pub name: Option<String>,
567
568    /// Internal plugin service mode.
569    #[arg(long, hide = true)]
570    pub plugin: Option<String>,
571
572    /// Update mesh-llm before continuing for release-bundle installs if a newer bundled release is available.
573    #[arg(long, global = true)]
574    pub auto_update: bool,
575
576    // ── Advanced options (hidden from default --help) ─────────────
577    /// Override speculative decoding (`mtp`, `ngram-cache`, `ngram-suffix`, or a package strategy id).
578    #[arg(long, hide = true)]
579    pub speculative_strategy: Option<String>,
580
581    /// Minimum matching N-gram length for the request-local MTP cache extension.
582    #[arg(long, hide = true)]
583    pub speculative_ngram_min: Option<u32>,
584
585    /// Maximum matching N-gram length for the request-local MTP cache extension.
586    #[arg(long, hide = true)]
587    pub speculative_ngram_max: Option<u32>,
588
589    /// Cap N-gram tokens proposed in one verify window.
590    #[arg(long, hide = true)]
591    pub speculative_ngram_max_proposal_tokens: Option<u32>,
592
593    /// Standalone N-gram proposer kind (`cache` or `suffix`).
594    #[arg(long, hide = true, value_enum)]
595    pub speculative_ngram_proposer: Option<SpeculativeNgramProposerCli>,
596
597    /// Maximum N-gram extension length for a composite MTP strategy.
598    #[arg(long, hide = true)]
599    pub speculative_extension_max_tokens: Option<u32>,
600
601    /// Native MTP rejection cooldown in generated tokens.
602    #[arg(long, hide = true)]
603    pub speculative_native_mtp_reject_cooldown_tokens: Option<u32>,
604
605    /// Suppress native MTP drafts while its rejection cooldown is active.
606    #[arg(long, hide = true)]
607    pub speculative_native_mtp_suppress_cooldown_drafts: bool,
608
609    /// Keep native MTP drafts during its rejection cooldown.
610    #[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    /// Maximum native MTP drafts suppressed by a cooldown.
618    #[arg(long, hide = true)]
619    pub speculative_native_mtp_suppress_cooldown_draft_limit: Option<u32>,
620
621    /// Minimum tokens to include in a pipelined verify window.
622    #[arg(long, hide = true)]
623    pub speculative_verify_window_min_tokens: Option<u32>,
624
625    /// Maximum tokens to include in a pipelined verify window.
626    #[arg(long, hide = true)]
627    pub speculative_verify_window_max_tokens: Option<u32>,
628
629    /// Number of in-flight pipelined verify windows.
630    #[arg(long, hide = true)]
631    pub speculative_verify_window_pipeline_depth: Option<u32>,
632
633    /// Draft model for speculative decoding.
634    #[arg(long, hide = true)]
635    pub draft: Option<PathBuf>,
636
637    /// Max draft tokens (default: 8).
638    #[arg(long, default_value = "8", hide = true)]
639    pub draft_max: u16,
640
641    /// Disable automatic draft model detection.
642    #[arg(long, hide = true)]
643    pub no_draft: bool,
644
645    /// Force tensor split even if the model fits on one node.
646    #[arg(long, hide = true)]
647    pub split: bool,
648
649    /// Pin split-serving node order and layer ranges from a JSON topology lock.
650    #[arg(long, value_name = "PATH", requires = "split", hide = true)]
651    pub split_topology_lock: Option<PathBuf>,
652
653    /// Override context size (tokens). Default: auto-scaled to available VRAM.
654    #[arg(long, hide = true)]
655    pub ctx_size: Option<u32>,
656
657    /// Cap VRAM used for planning, local-fit decisions, and mesh advertisement (GB).
658    #[arg(long)]
659    pub max_vram: Option<f64>,
660
661    /// Disable broadcasting GPU name, hostname, VRAM, and reserved bytes to peers. By default all nodes announce this hardware info.
662    #[arg(long = "no-enumerate-host", hide = true)]
663    pub no_enumerate_host: bool,
664
665    /// Path to bundled mesh support binaries.
666    #[arg(long, hide = true)]
667    pub bin_dir: Option<PathBuf>,
668
669    /// Override which bundled llama.cpp flavor to use.
670    #[arg(long, value_enum)]
671    pub llama_flavor: Option<BinaryFlavor>,
672
673    /// Device override for local backend selection.
674    #[arg(long, hide = true)]
675    pub device: Option<String>,
676
677    /// Deprecated tensor split override retained for CLI compatibility.
678    #[arg(long, hide = true)]
679    pub tensor_split: Option<String>,
680
681    /// Override iroh relay URLs.
682    #[arg(long, hide = true)]
683    pub relay: Vec<String>,
684
685    /// Per-relay bearer token for gated iroh relays, formatted as
686    /// `URL=TOKEN`. Repeatable. The token is sent as
687    /// `Authorization: Bearer <TOKEN>` on the WebSocket upgrade to the
688    /// matching `--relay` URL. Relays not listed here register without
689    /// authentication (the correct behavior for public relays).
690    ///
691    /// Splits on the first `=` only, so tokens may contain `=` (base64
692    /// padding, JWTs, etc.).
693    #[arg(long = "relay-auth", value_parser = parse_relay_auth_pair, hide = true)]
694    pub relay_auth: Vec<(String, String)>,
695
696    /// Disable iroh relays even when public mesh discovery would normally use them.
697    #[arg(long = "disable-iroh-relays", hide = true)]
698    pub disable_iroh_relays: bool,
699
700    /// Bind QUIC to a fixed UDP port (for NAT port forwarding).
701    #[arg(long, hide = true)]
702    pub bind_port: Option<u16>,
703
704    /// Bind mesh QUIC to a specific local IP address.
705    #[arg(long, hide = true)]
706    pub bind_ip: Option<IpAddr>,
707
708    /// Bind to 0.0.0.0 (for containers/Fly.io).
709    #[arg(long, hide = true)]
710    pub listen_all: bool,
711
712    /// Stop advertising when N clients connected.
713    #[arg(long, hide = true)]
714    pub max_clients: Option<usize>,
715
716    /// Custom Nostr relay URLs.
717    #[arg(long, hide = true)]
718    pub nostr_relay: Vec<String>,
719
720    /// Ignored (backward compat).
721    #[arg(long, hide = true)]
722    pub no_console: bool,
723
724    /// Optional path to the mesh-llm config file.
725    #[arg(long)]
726    pub config: Option<PathBuf>,
727
728    /// Path to the owner keystore used to attest this node.
729    #[arg(long)]
730    pub owner_key: Option<PathBuf>,
731
732    /// Bind address for the owner-control listener. Defaults to 127.0.0.1:0 when owner identity is configured.
733    #[arg(long, hide = true)]
734    pub control_bind: Option<std::net::SocketAddr>,
735
736    /// Advertised owner-control address encoded into the local-only bootstrap token.
737    #[arg(long, hide = true)]
738    pub control_advertise_addr: Option<std::net::SocketAddr>,
739
740    /// Fail startup if owner attestation cannot be loaded or signed.
741    #[arg(long)]
742    pub owner_required: bool,
743
744    /// Optional human label attached to this node certificate.
745    #[arg(long)]
746    pub node_label: Option<String>,
747
748    /// Override peer ownership trust policy.
749    #[arg(long, value_enum)]
750    pub trust_policy: Option<TrustPolicy>,
751
752    /// Add trusted owner IDs on top of the local trust store.
753    #[arg(long)]
754    pub trust_owner: Vec<String>,
755
756    /// Internal: set when this node joined via Nostr discovery (not --join).
757    #[arg(skip)]
758    pub nostr_discovery: bool,
759}
760
761#[derive(Subcommand, Debug)]
762pub enum Command {
763    /// Manage model storage, migration, and update checks.
764    Models {
765        #[command(subcommand)]
766        command: models::ModelsCommand,
767    },
768    /// Download a model from the remote catalog or Hugging Face
769    Download {
770        /// Model name (e.g. "Qwen2.5-32B-Instruct-Q4_K_M" or just "32b")
771        name: Option<String>,
772        /// Also download the recommended draft model for speculative decoding
773        #[arg(long)]
774        draft: bool,
775    },
776    /// Update mesh-llm to a bundled release and exit.
777    Update {
778        /// Install this specific release tag or version (e.g. v0.60.0 or 0.60.0-rc.1).
779        #[arg(long)]
780        version: Option<String>,
781        /// Install this release bundle flavor instead of the default installed flavor.
782        #[arg(long, value_enum, conflicts_with = "detect_flavor")]
783        flavor: Option<BinaryFlavor>,
784        /// Re-detect the best host backend flavor before selecting the release bundle.
785        #[arg(long, conflicts_with = "flavor")]
786        detect_flavor: bool,
787    },
788    /// Inspect local GPUs, stable IDs, and cached bandwidth.
789    #[command(alias = "gpu")]
790    Gpus {
791        /// Print machine-readable JSON output.
792        #[arg(long)]
793        json: bool,
794        #[command(subcommand)]
795        command: Option<GpuCommand>,
796    },
797    /// Inspect and manage native runtimes.
798    Runtime {
799        #[command(subcommand)]
800        command: Option<RuntimeCommand>,
801    },
802    /// Inspect and validate mesh-llm configuration files.
803    Config {
804        #[command(subcommand)]
805        command: ConfigCommand,
806    },
807    /// Diagnose local mesh, runtime, and split-readiness problems.
808    Doctor {
809        /// Print machine-readable JSON for the default doctor report.
810        #[arg(long)]
811        json: bool,
812        #[command(subcommand)]
813        command: Option<DoctorCommand>,
814    },
815    /// Bootstrap a new installation.
816    Setup {
817        /// Automatically answer yes to prompts.
818        #[arg(long)]
819        yes: bool,
820        /// Run without prompting for interactive input.
821        #[arg(long = "no-interactive")]
822        no_interactive: bool,
823        /// Install and enable the mesh-llm service.
824        #[arg(long, conflicts_with = "no_service")]
825        service: bool,
826        /// Skip installing and enabling the mesh-llm service.
827        #[arg(long = "no-service", conflicts_with = "service")]
828        no_service: bool,
829        /// Skip downloading or configuring the native runtime.
830        #[arg(long = "skip-runtime")]
831        skip_runtime: bool,
832        /// Print detailed setup paths, commands, and follow-up guidance.
833        #[arg(long)]
834        verbose: bool,
835    },
836    /// Remove mesh-llm binaries, service files, and optional caches.
837    Uninstall {
838        /// Print what would be removed without changing the machine.
839        #[arg(long)]
840        dry_run: bool,
841        /// Do not prompt before removing files and services.
842        #[arg(long)]
843        yes: bool,
844        /// Preserve native runtime caches.
845        #[arg(long)]
846        keep_cache: bool,
847        /// Preserve setup-owned service helper files.
848        #[arg(long)]
849        keep_service_files: bool,
850        /// Also remove ~/.mesh-llm configuration and identity data.
851        #[arg(long, conflicts_with = "keep_config")]
852        purge_config: bool,
853        /// Explicitly preserve ~/.mesh-llm configuration and identity data.
854        #[arg(long, conflicts_with = "purge_config")]
855        keep_config: bool,
856        /// Override the installed binary path to remove.
857        #[arg(long)]
858        binary_path: Option<std::path::PathBuf>,
859        /// Print machine-readable JSON.
860        #[arg(long)]
861        json: bool,
862        /// Print detailed cleanup steps and removed paths.
863        #[arg(long)]
864        verbose: bool,
865    },
866    /// Load a local model into a running mesh-llm instance.
867    Load {
868        /// Model name/path/url to load
869        name: String,
870        /// Console/API port of the running mesh-llm instance (default: 3131)
871        #[arg(long, default_value = "3131")]
872        port: u16,
873    },
874    /// Unload a local model from a running mesh-llm instance.
875    #[command(alias = "drop")]
876    Unload {
877        /// Model name to unload
878        name: String,
879        /// Console/API port of the running mesh-llm instance (default: 3131)
880        #[arg(long, default_value = "3131")]
881        port: u16,
882    },
883    /// Show local model status on a running mesh-llm instance.
884    Status {
885        /// Console/API port of the running mesh-llm instance (default: 3131)
886        #[arg(long, default_value = "3131")]
887        port: u16,
888    },
889    /// Discover meshes and optionally auto-join one.
890    Discover {
891        /// Filter by mesh name (case-insensitive exact match)
892        #[arg(long)]
893        name: Option<String>,
894        /// Filter by model name (substring match)
895        #[arg(long)]
896        model: Option<String>,
897        /// Filter by minimum VRAM (GB)
898        #[arg(long)]
899        min_vram: Option<f64>,
900        /// Filter by region
901        #[arg(long)]
902        region: Option<String>,
903        /// Print the invite token of the best match (for piping to --join)
904        #[arg(long)]
905        auto: bool,
906        /// Nostr relay URLs (default: see DEFAULT_RELAYS)
907        #[arg(long)]
908        relay: Vec<String>,
909    },
910    /// Rotate all identity keys (node + Nostr).
911    #[command(hide = true)]
912    RotateKey,
913    /// Launch Goose with mesh-llm as the inference provider.
914    ///
915    /// If no mesh is running on --port, this auto-joins the mesh as a client.
916    #[command(name = "goose")]
917    Goose {
918        /// Model id to use from /v1/models (default: auto = mesh picks best)
919        #[arg(long)]
920        model: Option<String>,
921        /// API port for mesh-llm (default: 9337)
922        #[arg(long, default_value = "9337")]
923        port: u16,
924    },
925    /// Launch Claude Code with mesh-llm as the inference provider.
926    ///
927    /// If no mesh is running on --port, this auto-joins the mesh as a client.
928    #[command(name = "claude")]
929    Claude {
930        /// Model id to use from /v1/models (default: auto = mesh picks best)
931        #[arg(long)]
932        model: Option<String>,
933        /// API port for mesh-llm (default: 9337)
934        #[arg(long, default_value = "9337")]
935        port: u16,
936    },
937    /// Launch pi with mesh-llm as the inference provider.
938    ///
939    /// If no mesh is running on a loopback/localhost target, this auto-joins the mesh as a client.
940    /// Writes a mesh provider into ~/.pi/agent/models.json and launches pi unless --write is set.
941    #[command(name = "pi")]
942    Pi {
943        /// Model id to use from /v1/models (default: auto = mesh picks best)
944        #[arg(long)]
945        model: Option<String>,
946        /// mesh-llm host or URL for Pi (default: 127.0.0.1:9337)
947        #[arg(long, default_value = "127.0.0.1:9337")]
948        host: String,
949        /// Write the mesh provider config to Pi's models.json instead of launching.
950        #[arg(long)]
951        write: bool,
952    },
953    /// Launch OpenCode with mesh-llm as the inference provider.
954    ///
955    /// If no mesh is running on a loopback/localhost target, this auto-joins the mesh as a client.
956    #[command(name = "opencode")]
957    Opencode {
958        /// Model id to use from /v1/models (default: auto = mesh picks best)
959        #[arg(long)]
960        model: Option<String>,
961        /// mesh-llm host or URL for OpenCode (default: 127.0.0.1:9337)
962        #[arg(long, default_value = "127.0.0.1:9337")]
963        host: String,
964        /// Write the mesh provider config to opencode's config file instead of launching.
965        #[arg(long)]
966        write: bool,
967    },
968    /// Stop running mesh-llm processes.
969    Stop,
970    /// Plugin management.
971    #[command(name = "plugins", alias = "plugin")]
972    Plugin {
973        #[command(subcommand)]
974        command: PluginCommand,
975    },
976    /// Install agent skills exposed by installed plugins.
977    Skills {
978        #[command(subcommand)]
979        command: SkillCommand,
980    },
981    /// Benchmark and compare model/runtime strategies.
982    Benchmark {
983        #[command(subcommand)]
984        command: BenchmarkCommand,
985    },
986    /// Prepare a model for distributed inference by splitting it into
987    /// per-layer files on HF compute.
988    ///
989    /// Submits an HF Job that builds skippy-model-package from source,
990    /// splits the model, publishes the layer package, and updates the
991    /// meshllm/catalog.
992    #[command(name = "model-prepare", hide = true, alias = "model-package")]
993    ModelPrepare {
994        /// Source HuggingFace model ref (e.g. unsloth/Qwen3-235B-A22B-GGUF:UD-Q4_K_XL).
995        source_repo: Option<String>,
996
997        /// Quantization variant (deprecated; prefer source refs like repo:Q4_K_M).
998        #[arg(long)]
999        quant: Option<String>,
1000
1001        /// Target repo for the layer package (auto-derived if omitted).
1002        #[arg(long)]
1003        target: Option<String>,
1004
1005        /// Override model ID in the manifest.
1006        #[arg(long)]
1007        model_id: Option<String>,
1008
1009        /// HF Job hardware flavor. Use auto for the default CPU splitter baseline.
1010        #[arg(long, default_value = "auto")]
1011        flavor: String,
1012
1013        /// Requested job timeout; raised automatically by model-size minimums.
1014        #[arg(long, default_value = "1h")]
1015        timeout: String,
1016
1017        /// Branch or tag of mesh-llm to build in the job [default: main].
1018        #[arg(long, default_value = "main")]
1019        mesh_llm_ref: String,
1020
1021        /// Explicitly keep this as a dry run. This is the default unless --confirm is set.
1022        #[arg(long)]
1023        dry_run: bool,
1024
1025        /// Actually submit the HF Job. Without this, the command only prints plan, spec, and max cost.
1026        #[arg(long)]
1027        confirm: bool,
1028
1029        /// Stream job logs after submission until completion.
1030        #[arg(long)]
1031        follow: bool,
1032
1033        /// Emit JSON output.
1034        #[arg(long)]
1035        json: bool,
1036
1037        /// Check status of a previously submitted job.
1038        #[arg(long)]
1039        status: Option<String>,
1040
1041        /// Fetch logs for a previously submitted job.
1042        #[arg(long)]
1043        logs: Option<String>,
1044
1045        /// Cancel a running job.
1046        #[arg(long)]
1047        cancel: Option<String>,
1048
1049        /// List recent model-package jobs.
1050        #[arg(long)]
1051        list: bool,
1052
1053        /// Upload the latest job script to the meshllm bucket (requires org access).
1054        #[arg(long)]
1055        update_script: bool,
1056    },
1057    /// Manage owner identity and keystore.
1058    Auth {
1059        #[command(subcommand)]
1060        command: AuthCommand,
1061    },
1062    /// Run a CLI command contributed by a configured plugin.
1063    #[command(external_subcommand)]
1064    ExternalPlugin(Vec<OsString>),
1065}
1066
1067#[derive(Subcommand, Debug)]
1068pub enum ConfigCommand {
1069    /// Validate a config TOML file without starting a node.
1070    Validate {
1071        /// Config TOML path to validate. Defaults to --config, MESH_LLM_CONFIG, or ~/.mesh-llm/config.toml.
1072        #[arg(long = "config-path")]
1073        config_path: Option<PathBuf>,
1074        /// Print machine-readable JSON output.
1075        #[arg(long)]
1076        json: bool,
1077    },
1078}
1079
1080#[derive(Subcommand, Debug)]
1081pub enum PluginCommand {
1082    /// Install a native plugin from the catalog, GitHub, or a local release archive.
1083    Install {
1084        /// Plugin catalog name, GitHub owner/repo, or GitHub URL.
1085        #[arg(required_unless_present = "archive", conflicts_with = "archive")]
1086        reference: Option<String>,
1087        /// Install a local .tar.gz or .zip release archive. Requires --name.
1088        #[arg(long, value_name = "PATH", requires = "name")]
1089        archive: Option<PathBuf>,
1090        /// Plugin name used to validate a local archive. Required with --archive.
1091        #[arg(long, requires = "archive")]
1092        name: Option<String>,
1093        /// Version recorded for a local archive install. Defaults to dev.
1094        #[arg(long, requires = "archive")]
1095        version: Option<String>,
1096    },
1097    /// Update an installed native plugin.
1098    Update {
1099        /// Plugin name.
1100        name: String,
1101    },
1102    /// Enable an installed native plugin.
1103    Enable {
1104        /// Plugin name.
1105        name: String,
1106    },
1107    /// Disable an installed native plugin.
1108    Disable {
1109        /// Plugin name.
1110        name: String,
1111    },
1112    /// Delete an installed native plugin.
1113    Delete {
1114        /// Plugin name.
1115        name: String,
1116    },
1117    /// Show installed plugin details.
1118    Info {
1119        /// Plugin name.
1120        name: String,
1121    },
1122    /// Search the plugin catalog.
1123    Search {
1124        /// Optional search query.
1125        query: Option<String>,
1126    },
1127    /// List installed, auto-registered, and configured plugins.
1128    List,
1129}
1130
1131#[derive(Subcommand, Debug)]
1132pub enum SkillCommand {
1133    /// Install skills exposed by installed plugins into supported agent skill folders.
1134    Install {
1135        /// Agent to install for. Repeat to install to several agents.
1136        #[arg(long, value_enum, conflicts_with = "all")]
1137        agent: Vec<SkillAgentArg>,
1138        /// Install to all supported agent locations, even if the agent is not detected.
1139        #[arg(long)]
1140        all: bool,
1141        /// Show what would be installed without writing files.
1142        #[arg(long)]
1143        dry_run: bool,
1144        /// Replace an existing non-mesh-managed skill with the same directory name.
1145        #[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    /// Diagnose split-readiness for a model on a running local mesh node.
1163    Split {
1164        /// Model ref/name to diagnose.
1165        #[arg(long, visible_alias = "model")]
1166        model_ref: String,
1167        /// Console/API port of the running mesh-llm instance.
1168        #[arg(long, default_value = "3131")]
1169        port: u16,
1170        /// Print machine-readable JSON.
1171        #[arg(long)]
1172        json: bool,
1173        /// Write a split and Skippy diagnostic bundle to this directory.
1174        #[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}