Skip to main content

mars_agents/cli/
models.rs

1//! CLI handlers for `mars models` subcommands.
2#![allow(clippy::print_literal)]
3
4use clap::{Parser, Subcommand};
5use indexmap::IndexMap;
6
7use crate::error::MarsError;
8use crate::models::{self, HarnessSource, ModelAlias, ModelSpec};
9use crate::types::MarsContext;
10
11/// Manage model aliases and the models cache.
12#[derive(Debug, Parser)]
13pub struct ModelsArgs {
14    #[command(subcommand)]
15    pub command: ModelsCommand,
16}
17
18#[derive(Debug, Subcommand)]
19pub enum ModelsCommand {
20    /// Fetch models from API and update the local cache.
21    Refresh,
22    /// List all model aliases (consumer + deps) with resolved IDs.
23    List(ListArgs),
24    /// Show resolution chain for a specific alias.
25    Resolve(ResolveAliasArgs),
26    /// Quick-add a pinned alias to mars.toml [models].
27    Alias(AddAliasArgs),
28}
29
30#[derive(Debug, Parser)]
31pub struct ListArgs {
32    /// Show all aliases including those without an available harness.
33    #[arg(long)]
34    all: bool,
35    /// Skip automatic models-cache refresh; use whatever's on disk (equivalent to MARS_OFFLINE=1).
36    #[arg(long)]
37    no_refresh_models: bool,
38    /// Only show aliases matching these patterns (overrides config).
39    #[arg(long, value_delimiter = ',', conflicts_with = "exclude")]
40    include: Option<Vec<String>>,
41    /// Hide aliases matching these patterns (overrides config).
42    #[arg(long, value_delimiter = ',', conflicts_with = "include")]
43    exclude: Option<Vec<String>>,
44}
45
46#[derive(Debug, Parser)]
47pub struct ResolveAliasArgs {
48    /// Alias name to resolve.
49    pub name: String,
50    /// Skip automatic models-cache refresh; use whatever's on disk (equivalent to MARS_OFFLINE=1).
51    #[arg(long)]
52    no_refresh_models: bool,
53}
54
55#[derive(Debug, Parser)]
56pub struct AddAliasArgs {
57    /// Alias name.
58    pub name: String,
59    /// Model ID to pin.
60    pub model_id: String,
61    /// Harness for this alias (default: claude).
62    #[arg(long, default_value = "claude")]
63    pub harness: String,
64    /// Optional description.
65    #[arg(long)]
66    pub description: Option<String>,
67}
68
69pub fn run(args: &ModelsArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
70    match &args.command {
71        ModelsCommand::Refresh => run_refresh(ctx, json),
72        ModelsCommand::List(args) => run_list(args, ctx, json),
73        ModelsCommand::Resolve(a) => run_resolve(a, ctx, json),
74        ModelsCommand::Alias(a) => run_alias(a, ctx, json),
75    }
76}
77
78fn mars_dir(ctx: &MarsContext) -> std::path::PathBuf {
79    ctx.project_root.join(".mars")
80}
81
82fn run_refresh(ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
83    let mars = mars_dir(ctx);
84    let ttl = models::load_models_cache_ttl(ctx);
85    eprint!("Fetching models catalog... ");
86
87    let (cache, outcome) = models::ensure_fresh(&mars, ttl, models::RefreshMode::Force)?;
88    let count = cache.models.len();
89    let cache_warning = cache_warning(&outcome);
90
91    if let Some(warning) = cache_warning.as_deref() {
92        eprintln!("warning: {warning}");
93    } else if !json {
94        eprintln!("done.");
95    }
96
97    if json {
98        let out = serde_json::json!({
99            "status": "ok",
100            "models_count": count,
101            "fetched_at": cache.fetched_at,
102        });
103        let mut out = out;
104        if let Some(warning) = cache_warning.as_deref() {
105            out["cache_warning"] = serde_json::json!(warning);
106        }
107        println!("{}", serde_json::to_string_pretty(&out).unwrap());
108    } else {
109        if cache_warning.is_some() {
110            println!(
111                "Using stale models cache with {} models in .mars/models-cache.json",
112                count
113            );
114        } else {
115            println!("Cached {} models in .mars/models-cache.json", count);
116        }
117    }
118
119    Ok(0)
120}
121
122fn run_list(args: &ListArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
123    let mars = mars_dir(ctx);
124    let ttl = models::load_models_cache_ttl(ctx);
125    let mode = models::resolve_refresh_mode(args.no_refresh_models);
126    let (cache, outcome) = match models::ensure_fresh(&mars, ttl, mode) {
127        Ok(ok) => ok,
128        Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
129            println!(
130                "{}",
131                serde_json::to_string_pretty(&serde_json::json!({
132                    "error": format!("{err}"),
133                }))
134                .unwrap()
135            );
136            return Ok(1);
137        }
138        Err(err) => return Err(err),
139    };
140    let cache_warning = cache_warning(&outcome);
141
142    // Load config to get consumer models + trigger merge
143    let merged = load_merged_aliases(ctx)?;
144    let resolved = models::resolve_all(&merged, &cache);
145
146    // Build effective visibility: CLI overrides config entirely.
147    let config_visibility = crate::config::load(&ctx.project_root)
148        .map(|c| c.settings.model_visibility)
149        .unwrap_or_default();
150
151    let visibility = if args.include.is_some() || args.exclude.is_some() {
152        crate::config::ModelVisibility {
153            include: args.include.clone(),
154            exclude: args.exclude.clone(),
155        }
156    } else {
157        config_visibility
158    };
159
160    let resolved = models::filter_by_visibility(resolved, &visibility);
161
162    if json {
163        let entries: Vec<serde_json::Value> = resolved
164            .values()
165            .map(|r| {
166                let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
167                let mut obj = serde_json::json!({
168                    "name": r.name,
169                    "harness": r.harness,
170                    "harness_source": r.harness_source,
171                    "harness_candidates": r.harness_candidates,
172                    "provider": r.provider,
173                    "mode": mode,
174                    "model_id": r.model_id,
175                    "resolved_model": r.model_id,
176                    "description": r.description,
177                });
178                if let Some(error) = unavailable_harness_error(r) {
179                    obj["error"] = serde_json::json!(error);
180                }
181                obj
182            })
183            .collect();
184        let mut out = serde_json::json!({
185            "aliases": entries,
186            "cache_available": cache.fetched_at.is_some(),
187        });
188        if let Some(warning) = cache_warning.as_deref() {
189            out["cache_warning"] = serde_json::json!(warning);
190        }
191        println!("{}", serde_json::to_string_pretty(&out).unwrap());
192    } else {
193        if let Some(warning) = cache_warning.as_deref() {
194            eprintln!("warning: {warning}");
195        }
196        // Table output
197        println!(
198            "{:<12} {:<10} {:<14} {:<30} {}",
199            "ALIAS", "HARNESS", "MODE", "RESOLVED", "DESCRIPTION"
200        );
201        for r in resolved.values() {
202            if !args.all && r.harness_source == HarnessSource::Unavailable {
203                continue;
204            }
205            let harness = r.harness.as_deref().unwrap_or("—");
206            let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
207            let desc = if r.harness_source == HarnessSource::Unavailable {
208                format!("(install: {})", r.harness_candidates.join(", "))
209            } else {
210                r.description.clone().unwrap_or_default()
211            };
212            println!(
213                "{:<12} {:<10} {:<14} {:<30} {}",
214                r.name, harness, mode, r.model_id, desc
215            );
216        }
217    }
218
219    Ok(0)
220}
221
222fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
223    let merged = load_merged_aliases(ctx)?;
224    let Some(alias) = merged.get(&args.name) else {
225        if json {
226            println!(
227                "{}",
228                serde_json::to_string_pretty(&serde_json::json!({
229                    "error": format!("unknown alias: {}", args.name),
230                }))
231                .unwrap()
232            );
233        } else {
234            eprintln!("error: unknown alias `{}`", args.name);
235        }
236        return Ok(1);
237    };
238
239    let mars = mars_dir(ctx);
240    let ttl = models::load_models_cache_ttl(ctx);
241    let mode = models::resolve_refresh_mode(args.no_refresh_models);
242    let (cache, outcome) = match models::ensure_fresh(&mars, ttl, mode) {
243        Ok(ok) => ok,
244        Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
245            println!(
246                "{}",
247                serde_json::to_string_pretty(&serde_json::json!({
248                    "error": format!("{err}"),
249                }))
250                .unwrap()
251            );
252            return Ok(1);
253        }
254        Err(err) => return Err(err),
255    };
256    let cache_warning = cache_warning(&outcome);
257
258    if let Some(warning) = cache_warning.as_deref()
259        && !json
260    {
261        eprintln!("warning: {warning}");
262    }
263
264    // Determine source layer
265    let source = determine_source(&args.name, ctx)?;
266    let resolved_map = models::resolve_all(&merged, &cache);
267    let resolved_entry = resolved_map.get(&args.name);
268
269    if json {
270        if let Some(r) = resolved_entry {
271            let mut out = serde_json::json!({
272                "name": r.name,
273                "source": source,
274                "provider": r.provider,
275                "harness": r.harness,
276                "harness_source": r.harness_source,
277                "harness_candidates": r.harness_candidates,
278                "model_id": r.model_id,
279                "resolved_model": r.model_id,
280                "spec": format_spec(&alias.spec),
281                "description": r.description,
282            });
283            if let Some(error) = unavailable_harness_error(r) {
284                out["error"] = serde_json::json!(error);
285            }
286            if let Some(warning) = cache_warning.as_deref() {
287                out["cache_warning"] = serde_json::json!(warning);
288            }
289            println!("{}", serde_json::to_string_pretty(&out).unwrap());
290        } else {
291            let mut out = serde_json::json!({
292                "error": format!("alias `{}` did not resolve to a model ID", args.name),
293            });
294            if let Some(warning) = cache_warning.as_deref() {
295                out["cache_warning"] = serde_json::json!(warning);
296            }
297            println!("{}", serde_json::to_string_pretty(&out).unwrap());
298            return Ok(1);
299        }
300    } else {
301        let Some(r) = resolved_entry else {
302            eprintln!("error: alias `{}` did not resolve to a model ID", args.name);
303            return Ok(1);
304        };
305        let harness = r.harness.as_deref().unwrap_or("—");
306        println!("Alias:    {}", args.name);
307        println!("Source:   {}", source);
308        println!(
309            "Harness:  {} ({})",
310            harness,
311            harness_source_label(&r.harness_source)
312        );
313        println!("Provider: {}", r.provider);
314        match &alias.spec {
315            ModelSpec::Pinned { model, provider: _ } => {
316                println!("Mode:     pinned");
317                println!("Model:    {}", model);
318            }
319            ModelSpec::AutoResolve {
320                provider: _,
321                match_patterns,
322                exclude_patterns,
323            } => {
324                println!("Mode:     auto-resolve");
325                println!("Match:    {}", match_patterns.join(", "));
326                if !exclude_patterns.is_empty() {
327                    println!("Exclude:  {}", exclude_patterns.join(", "));
328                }
329                println!("Resolved: {}", r.model_id);
330            }
331        }
332        if let Some(error) = unavailable_harness_error(r) {
333            println!("Error:    {}", error);
334        }
335        if let Some(desc) = &r.description {
336            println!("Desc:     {}", desc);
337        }
338    }
339
340    Ok(0)
341}
342
343fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
344    let mut config = crate::config::load(&ctx.project_root)?;
345    config.models.insert(
346        args.name.clone(),
347        ModelAlias {
348            harness: Some(args.harness.clone()),
349            description: args.description.clone(),
350            spec: ModelSpec::Pinned {
351                model: args.model_id.clone(),
352                provider: None,
353            },
354        },
355    );
356    crate::config::save(&ctx.project_root, &config)?;
357
358    if json {
359        println!(
360            "{}",
361            serde_json::to_string_pretty(&serde_json::json!({
362                "status": "ok",
363                "alias": args.name,
364                "model": args.model_id,
365                "harness": args.harness,
366            }))
367            .unwrap()
368        );
369    } else {
370        println!(
371            "Added alias `{}` → {} (harness: {})",
372            args.name, args.model_id, args.harness
373        );
374    }
375
376    Ok(0)
377}
378
379// ---------------------------------------------------------------------------
380// Helpers
381// ---------------------------------------------------------------------------
382
383/// Load model aliases by combining cached dependency aliases with consumer config.
384fn load_merged_aliases(
385    ctx: &MarsContext,
386) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
387    // Start with builtins (lowest precedence)
388    let mut merged = models::builtin_aliases();
389
390    // Layer dep aliases from cached merge file (overrides builtins)
391    let mars_dir = ctx.project_root.join(".mars");
392    let merged_path = mars_dir.join("models-merged.json");
393    if let Ok(content) = std::fs::read_to_string(&merged_path)
394        && let Ok(cached) = serde_json::from_str::<IndexMap<String, ModelAlias>>(&content)
395    {
396        for (name, alias) in cached {
397            merged.insert(name, alias);
398        }
399    }
400
401    // Layer consumer config on top (highest precedence)
402    if let Ok(config) = crate::config::load(&ctx.project_root) {
403        for (name, alias) in &config.models {
404            merged.insert(name.clone(), alias.clone());
405        }
406    }
407
408    Ok(merged)
409}
410
411/// Determine which layer provides an alias (consumer or dependency).
412fn determine_source(name: &str, ctx: &MarsContext) -> Result<String, MarsError> {
413    let config = match crate::config::load(&ctx.project_root) {
414        Ok(c) => c,
415        Err(_) => return Ok("unknown".to_string()),
416    };
417
418    if config.models.contains_key(name) {
419        return Ok("consumer (mars.toml)".to_string());
420    }
421
422    Ok("dependency".to_string())
423}
424
425fn format_spec(spec: &ModelSpec) -> serde_json::Value {
426    match spec {
427        ModelSpec::Pinned { model, provider } => {
428            let mut out = serde_json::json!({ "mode": "pinned", "model": model });
429            if let Some(provider) = provider {
430                out["provider"] = serde_json::json!(provider);
431            }
432            out
433        }
434        ModelSpec::AutoResolve {
435            provider,
436            match_patterns,
437            exclude_patterns,
438        } => serde_json::json!({
439            "mode": "auto-resolve",
440            "provider": provider,
441            "match": match_patterns,
442            "exclude": exclude_patterns,
443        }),
444    }
445}
446
447fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
448    match spec {
449        Some(ModelSpec::Pinned { .. }) => "pinned",
450        Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
451        None => "unknown",
452    }
453}
454
455fn harness_source_label(source: &HarnessSource) -> &'static str {
456    match source {
457        HarnessSource::Explicit => "explicit",
458        HarnessSource::AutoDetected => "auto-detected",
459        HarnessSource::Unavailable => "unavailable",
460    }
461}
462
463fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
464    if resolved.harness_source != HarnessSource::Unavailable {
465        return None;
466    }
467    if let Some(h) = &resolved.harness {
468        Some(format!("Harness '{}' is not installed", h))
469    } else {
470        Some(format!(
471            "No installed harness for provider '{}'. Install one of: {}",
472            resolved.provider,
473            resolved.harness_candidates.join(", ")
474        ))
475    }
476}
477
478fn stale_warning(reason: &str) -> String {
479    format!("models cache refresh failed: {reason}; using stale cache")
480}
481
482fn cache_warning(outcome: &models::RefreshOutcome) -> Option<String> {
483    match outcome {
484        models::RefreshOutcome::StaleFallback { reason } => Some(stale_warning(reason)),
485        _ => None,
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use clap::Parser;
493    use tempfile::TempDir;
494
495    fn write_mars_toml(temp: &TempDir, contents: &str) {
496        std::fs::write(temp.path().join("mars.toml"), contents).unwrap();
497    }
498
499    fn normalized_exit_code(result: Result<i32, MarsError>) -> i32 {
500        match result {
501            Ok(code) => code,
502            Err(err) => err.exit_code(),
503        }
504    }
505
506    #[test]
507    fn list_args_parses_no_refresh_models() {
508        let args = ListArgs::try_parse_from(["mars", "--no-refresh-models"]).unwrap();
509        assert!(args.no_refresh_models);
510    }
511
512    #[test]
513    fn resolve_alias_args_parses_no_refresh_models() {
514        let args =
515            ResolveAliasArgs::try_parse_from(["mars", "opus", "--no-refresh-models"]).unwrap();
516        assert!(args.no_refresh_models);
517    }
518
519    #[test]
520    fn list_no_refresh_without_cache_is_non_zero() {
521        let temp = TempDir::new().unwrap();
522        write_mars_toml(&temp, "[settings]\n");
523        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
524        let args = ModelsArgs::try_parse_from(["mars", "list", "--no-refresh-models"]).unwrap();
525
526        let exit = normalized_exit_code(run(&args, &ctx, false));
527        assert_ne!(exit, 0);
528    }
529
530    #[test]
531    fn resolve_no_refresh_without_cache_is_non_zero() {
532        let temp = TempDir::new().unwrap();
533        write_mars_toml(
534            &temp,
535            r#"[settings]
536
537[models.opus]
538harness = "claude"
539model = "claude-opus-4-6"
540"#,
541        );
542        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
543        let args =
544            ModelsArgs::try_parse_from(["mars", "resolve", "opus", "--no-refresh-models"]).unwrap();
545
546        let exit = normalized_exit_code(run(&args, &ctx, false));
547        assert_ne!(exit, 0);
548    }
549
550    #[test]
551    fn alias_updates_existing_model_entry() {
552        let temp = TempDir::new().unwrap();
553        write_mars_toml(
554            &temp,
555            r#"[settings]
556
557[models.fast]
558harness = "claude"
559model = "claude-3-5-sonnet"
560description = "Old alias"
561"#,
562        );
563        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
564
565        let args = AddAliasArgs {
566            name: "fast".to_string(),
567            model_id: "gpt-5.3-codex".to_string(),
568            harness: "codex".to_string(),
569            description: Some("Updated alias".to_string()),
570        };
571
572        let exit = run_alias(&args, &ctx, false).unwrap();
573        assert_eq!(exit, 0);
574
575        let config = crate::config::load(temp.path()).unwrap();
576        assert_eq!(config.models.len(), 1);
577
578        let alias = config.models.get("fast").unwrap();
579        assert_eq!(alias.harness.as_deref(), Some("codex"));
580        assert_eq!(alias.description.as_deref(), Some("Updated alias"));
581        match &alias.spec {
582            ModelSpec::Pinned { model, provider } => {
583                assert_eq!(model, "gpt-5.3-codex");
584                assert_eq!(provider, &None);
585            }
586            _ => panic!("expected pinned alias"),
587        }
588    }
589}