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