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, ModelsCache};
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    /// Only show aliases matching these patterns (overrides config).
36    #[arg(long, value_delimiter = ',', conflicts_with = "exclude")]
37    include: Option<Vec<String>>,
38    /// Hide aliases matching these patterns (overrides config).
39    #[arg(long, value_delimiter = ',', conflicts_with = "include")]
40    exclude: Option<Vec<String>>,
41}
42
43#[derive(Debug, Parser)]
44pub struct ResolveAliasArgs {
45    /// Alias name to resolve.
46    pub name: String,
47}
48
49#[derive(Debug, Parser)]
50pub struct AddAliasArgs {
51    /// Alias name.
52    pub name: String,
53    /// Model ID to pin.
54    pub model_id: String,
55    /// Harness for this alias (default: claude).
56    #[arg(long, default_value = "claude")]
57    pub harness: String,
58    /// Optional description.
59    #[arg(long)]
60    pub description: Option<String>,
61}
62
63pub fn run(args: &ModelsArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
64    match &args.command {
65        ModelsCommand::Refresh => run_refresh(ctx, json),
66        ModelsCommand::List(args) => run_list(args, ctx, json),
67        ModelsCommand::Resolve(a) => run_resolve(a, ctx, json),
68        ModelsCommand::Alias(a) => run_alias(a, ctx, json),
69    }
70}
71
72fn mars_dir(ctx: &MarsContext) -> std::path::PathBuf {
73    ctx.project_root.join(".mars")
74}
75
76fn run_refresh(ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
77    let mars = mars_dir(ctx);
78    eprint!("Fetching models catalog... ");
79
80    let fetched = models::fetch_models()?;
81    let count = fetched.len();
82    let cache = ModelsCache {
83        models: fetched,
84        fetched_at: Some(now_iso()),
85    };
86    models::write_cache(&mars, &cache)?;
87
88    if json {
89        let out = serde_json::json!({
90            "status": "ok",
91            "models_count": count,
92            "fetched_at": cache.fetched_at,
93        });
94        println!("{}", serde_json::to_string_pretty(&out).unwrap());
95    } else {
96        eprintln!("done.");
97        println!("Cached {} models in .mars/models-cache.json", count);
98    }
99
100    Ok(0)
101}
102
103fn run_list(args: &ListArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
104    let mars = mars_dir(ctx);
105    let cache = models::read_cache(&mars)?;
106
107    // Load config to get consumer models + trigger merge
108    let merged = load_merged_aliases(ctx)?;
109    let resolved = models::resolve_all(&merged, &cache);
110
111    // Build effective visibility: CLI overrides config entirely.
112    let config_visibility = crate::config::load(&ctx.project_root)
113        .map(|c| c.settings.model_visibility)
114        .unwrap_or_default();
115
116    let visibility = if args.include.is_some() || args.exclude.is_some() {
117        crate::config::ModelVisibility {
118            include: args.include.clone(),
119            exclude: args.exclude.clone(),
120        }
121    } else {
122        config_visibility
123    };
124
125    let resolved = models::filter_by_visibility(resolved, &visibility);
126
127    if json {
128        let entries: Vec<serde_json::Value> = resolved
129            .values()
130            .map(|r| {
131                let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
132                let mut obj = serde_json::json!({
133                    "name": r.name,
134                    "harness": r.harness,
135                    "harness_source": r.harness_source,
136                    "harness_candidates": r.harness_candidates,
137                    "provider": r.provider,
138                    "mode": mode,
139                    "model_id": r.model_id,
140                    "resolved_model": r.model_id,
141                    "description": r.description,
142                });
143                if let Some(error) = unavailable_harness_error(r) {
144                    obj["error"] = serde_json::json!(error);
145                }
146                obj
147            })
148            .collect();
149        println!(
150            "{}",
151            serde_json::to_string_pretty(&serde_json::json!({
152                "aliases": entries,
153                "cache_available": cache.fetched_at.is_some(),
154            }))
155            .unwrap()
156        );
157    } else {
158        if cache.fetched_at.is_none() {
159            eprintln!(
160                "hint: no models cache — run `mars models refresh` for auto-resolve support."
161            );
162            eprintln!();
163        }
164        // Table output
165        println!(
166            "{:<12} {:<10} {:<14} {:<30} {}",
167            "ALIAS", "HARNESS", "MODE", "RESOLVED", "DESCRIPTION"
168        );
169        for r in resolved.values() {
170            if !args.all && r.harness_source == HarnessSource::Unavailable {
171                continue;
172            }
173            let harness = r.harness.as_deref().unwrap_or("—");
174            let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
175            let desc = if r.harness_source == HarnessSource::Unavailable {
176                format!("(install: {})", r.harness_candidates.join(", "))
177            } else {
178                r.description.clone().unwrap_or_default()
179            };
180            println!(
181                "{:<12} {:<10} {:<14} {:<30} {}",
182                r.name, harness, mode, r.model_id, desc
183            );
184        }
185    }
186
187    Ok(0)
188}
189
190fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
191    let mars = mars_dir(ctx);
192    let cache = models::read_cache(&mars)?;
193    let merged = load_merged_aliases(ctx)?;
194
195    let Some(alias) = merged.get(&args.name) else {
196        if json {
197            println!(
198                "{}",
199                serde_json::to_string_pretty(&serde_json::json!({
200                    "error": format!("unknown alias: {}", args.name),
201                }))
202                .unwrap()
203            );
204        } else {
205            eprintln!("error: unknown alias `{}`", args.name);
206        }
207        return Ok(1);
208    };
209
210    // Determine source layer
211    let source = determine_source(&args.name, ctx)?;
212    let resolved_map = models::resolve_all(&merged, &cache);
213    let resolved_entry = resolved_map.get(&args.name);
214
215    if json {
216        if let Some(r) = resolved_entry {
217            let mut out = serde_json::json!({
218                "name": r.name,
219                "source": source,
220                "provider": r.provider,
221                "harness": r.harness,
222                "harness_source": r.harness_source,
223                "harness_candidates": r.harness_candidates,
224                "model_id": r.model_id,
225                "resolved_model": r.model_id,
226                "spec": format_spec(&alias.spec),
227                "description": r.description,
228            });
229            if let Some(error) = unavailable_harness_error(r) {
230                out["error"] = serde_json::json!(error);
231            }
232            println!("{}", serde_json::to_string_pretty(&out).unwrap());
233        } else {
234            println!(
235                "{}",
236                serde_json::to_string_pretty(&serde_json::json!({
237                    "error": format!("alias `{}` did not resolve to a model ID", args.name),
238                }))
239                .unwrap()
240            );
241            return Ok(1);
242        }
243    } else {
244        let Some(r) = resolved_entry else {
245            eprintln!("error: alias `{}` did not resolve to a model ID", args.name);
246            return Ok(1);
247        };
248        let harness = r.harness.as_deref().unwrap_or("—");
249        println!("Alias:    {}", args.name);
250        println!("Source:   {}", source);
251        println!(
252            "Harness:  {} ({})",
253            harness,
254            harness_source_label(&r.harness_source)
255        );
256        println!("Provider: {}", r.provider);
257        match &alias.spec {
258            ModelSpec::Pinned { model, provider: _ } => {
259                println!("Mode:     pinned");
260                println!("Model:    {}", model);
261            }
262            ModelSpec::AutoResolve {
263                provider: _,
264                match_patterns,
265                exclude_patterns,
266            } => {
267                println!("Mode:     auto-resolve");
268                println!("Match:    {}", match_patterns.join(", "));
269                if !exclude_patterns.is_empty() {
270                    println!("Exclude:  {}", exclude_patterns.join(", "));
271                }
272                println!("Resolved: {}", r.model_id);
273            }
274        }
275        if let Some(error) = unavailable_harness_error(r) {
276            println!("Error:    {}", error);
277        }
278        if let Some(desc) = &r.description {
279            println!("Desc:     {}", desc);
280        }
281    }
282
283    Ok(0)
284}
285
286fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
287    let config_path = ctx.project_root.join("mars.toml");
288
289    // Read existing config
290    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
291
292    let harness = Some(args.harness.clone());
293
294    // Build the TOML entry
295    let mut entry = format!(
296        "\n[models.{}]\nharness = {:?}\nmodel = {:?}\n",
297        args.name,
298        harness.as_deref().unwrap_or("claude"),
299        args.model_id
300    );
301    if let Some(desc) = &args.description {
302        entry.push_str(&format!("description = {:?}\n", desc));
303    }
304
305    // Append to mars.toml
306    let new_content = if content.is_empty() {
307        entry
308    } else {
309        format!("{}{}", content.trim_end(), entry)
310    };
311    std::fs::write(&config_path, new_content)?;
312
313    if json {
314        println!(
315            "{}",
316            serde_json::to_string_pretty(&serde_json::json!({
317                "status": "ok",
318                "alias": args.name,
319                "model": args.model_id,
320                "harness": args.harness,
321            }))
322            .unwrap()
323        );
324    } else {
325        println!(
326            "Added alias `{}` → {} (harness: {})",
327            args.name, args.model_id, args.harness
328        );
329    }
330
331    Ok(0)
332}
333
334// ---------------------------------------------------------------------------
335// Helpers
336// ---------------------------------------------------------------------------
337
338/// Load model aliases by combining cached dependency aliases with consumer config.
339fn load_merged_aliases(
340    ctx: &MarsContext,
341) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
342    // Start with builtins (lowest precedence)
343    let mut merged = models::builtin_aliases();
344
345    // Layer dep aliases from cached merge file (overrides builtins)
346    let mars_dir = ctx.project_root.join(".mars");
347    let merged_path = mars_dir.join("models-merged.json");
348    if let Ok(content) = std::fs::read_to_string(&merged_path)
349        && let Ok(cached) = serde_json::from_str::<IndexMap<String, ModelAlias>>(&content)
350    {
351        for (name, alias) in cached {
352            merged.insert(name, alias);
353        }
354    }
355
356    // Layer consumer config on top (highest precedence)
357    if let Ok(config) = crate::config::load(&ctx.project_root) {
358        for (name, alias) in &config.models {
359            merged.insert(name.clone(), alias.clone());
360        }
361    }
362
363    Ok(merged)
364}
365
366/// Determine which layer provides an alias (consumer or dependency).
367fn determine_source(name: &str, ctx: &MarsContext) -> Result<String, MarsError> {
368    let config = match crate::config::load(&ctx.project_root) {
369        Ok(c) => c,
370        Err(_) => return Ok("unknown".to_string()),
371    };
372
373    if config.models.contains_key(name) {
374        return Ok("consumer (mars.toml)".to_string());
375    }
376
377    Ok("dependency".to_string())
378}
379
380fn format_spec(spec: &ModelSpec) -> serde_json::Value {
381    match spec {
382        ModelSpec::Pinned { model, provider } => {
383            let mut out = serde_json::json!({ "mode": "pinned", "model": model });
384            if let Some(provider) = provider {
385                out["provider"] = serde_json::json!(provider);
386            }
387            out
388        }
389        ModelSpec::AutoResolve {
390            provider,
391            match_patterns,
392            exclude_patterns,
393        } => serde_json::json!({
394            "mode": "auto-resolve",
395            "provider": provider,
396            "match": match_patterns,
397            "exclude": exclude_patterns,
398        }),
399    }
400}
401
402fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
403    match spec {
404        Some(ModelSpec::Pinned { .. }) => "pinned",
405        Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
406        None => "unknown",
407    }
408}
409
410fn harness_source_label(source: &HarnessSource) -> &'static str {
411    match source {
412        HarnessSource::Explicit => "explicit",
413        HarnessSource::AutoDetected => "auto-detected",
414        HarnessSource::Unavailable => "unavailable",
415    }
416}
417
418fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
419    if resolved.harness_source != HarnessSource::Unavailable {
420        return None;
421    }
422    if let Some(h) = &resolved.harness {
423        Some(format!("Harness '{}' is not installed", h))
424    } else {
425        Some(format!(
426            "No installed harness for provider '{}'. Install one of: {}",
427            resolved.provider,
428            resolved.harness_candidates.join(", ")
429        ))
430    }
431}
432
433fn now_iso() -> String {
434    // Simple ISO timestamp without external chrono dep
435    use std::time::SystemTime;
436    let dur = SystemTime::now()
437        .duration_since(SystemTime::UNIX_EPOCH)
438        .unwrap_or_default();
439    let secs = dur.as_secs();
440    // Format as a simple timestamp string
441    format!("{secs}")
442}