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