1#![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#[derive(Debug, Parser)]
13pub struct ModelsArgs {
14 #[command(subcommand)]
15 pub command: ModelsCommand,
16}
17
18#[derive(Debug, Subcommand)]
19pub enum ModelsCommand {
20 Refresh,
22 List(ListArgs),
24 Resolve(ResolveAliasArgs),
26 Alias(AddAliasArgs),
28}
29
30#[derive(Debug, Parser)]
31pub struct ListArgs {
32 #[arg(long)]
34 all: bool,
35 #[arg(long, value_delimiter = ',', conflicts_with = "exclude")]
37 include: Option<Vec<String>>,
38 #[arg(long, value_delimiter = ',', conflicts_with = "include")]
40 exclude: Option<Vec<String>>,
41}
42
43#[derive(Debug, Parser)]
44pub struct ResolveAliasArgs {
45 pub name: String,
47}
48
49#[derive(Debug, Parser)]
50pub struct AddAliasArgs {
51 pub name: String,
53 pub model_id: String,
55 #[arg(long, default_value = "claude")]
57 pub harness: String,
58 #[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 let merged = load_merged_aliases(ctx)?;
109 let resolved = models::resolve_all(&merged, &cache);
110
111 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 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 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 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
291
292 let harness = Some(args.harness.clone());
293
294 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 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
334fn load_merged_aliases(
340 ctx: &MarsContext,
341) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
342 let mut merged = models::builtin_aliases();
344
345 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 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
366fn 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 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!("{secs}")
442}