1#![allow(clippy::print_literal)]
3
4use clap::{Parser, Subcommand};
5use indexmap::IndexMap;
6use std::collections::HashSet;
7
8use crate::diagnostic::{Diagnostic, DiagnosticCollector, DiagnosticLevel};
9use crate::error::MarsError;
10use crate::models::{self, HarnessSource, ModelAlias, ModelSpec};
11use crate::types::MarsContext;
12
13#[derive(Debug, Parser)]
15pub struct ModelsArgs {
16 #[command(subcommand)]
17 pub command: ModelsCommand,
18}
19
20#[derive(Debug, Subcommand)]
21pub enum ModelsCommand {
22 Refresh,
24 List(ListArgs),
26 Resolve(ResolveAliasArgs),
28 Alias(AddAliasArgs),
30}
31
32#[derive(Debug, Parser)]
33pub struct ListArgs {
34 #[arg(
36 long,
37 conflicts_with = "catalog",
38 conflicts_with = "include",
39 conflicts_with = "exclude"
40 )]
41 all: bool,
42 #[arg(long)]
44 no_refresh_models: bool,
45 #[arg(
47 long,
48 value_delimiter = ',',
49 conflicts_with = "exclude",
50 conflicts_with = "catalog",
51 conflicts_with = "all"
52 )]
53 include: Option<Vec<String>>,
54 #[arg(
56 long,
57 value_delimiter = ',',
58 conflicts_with = "include",
59 conflicts_with = "catalog",
60 conflicts_with = "all"
61 )]
62 exclude: Option<Vec<String>>,
63 #[arg(
65 long,
66 conflicts_with = "include",
67 conflicts_with = "exclude",
68 conflicts_with = "all"
69 )]
70 catalog: bool,
71}
72
73#[derive(Debug, Parser)]
74pub struct ResolveAliasArgs {
75 pub name: String,
77 #[arg(long)]
79 no_refresh_models: bool,
80}
81
82#[derive(Debug, Parser)]
83pub struct AddAliasArgs {
84 pub name: String,
86 pub model_id: String,
88 #[arg(long, default_value = "claude")]
90 pub harness: String,
91 #[arg(long)]
93 pub description: Option<String>,
94}
95
96pub fn run(args: &ModelsArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
97 match &args.command {
98 ModelsCommand::Refresh => run_refresh(ctx, json),
99 ModelsCommand::List(args) => run_list(args, ctx, json),
100 ModelsCommand::Resolve(a) => run_resolve(a, ctx, json),
101 ModelsCommand::Alias(a) => run_alias(a, ctx, json),
102 }
103}
104
105fn mars_dir(ctx: &MarsContext) -> std::path::PathBuf {
106 ctx.project_root.join(".mars")
107}
108
109fn run_refresh(ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
110 let mars = mars_dir(ctx);
111 let ttl = models::load_models_cache_ttl(ctx);
112 eprint!("Fetching models catalog... ");
113
114 let (cache, outcome) = models::ensure_fresh(&mars, ttl, models::RefreshMode::Force)?;
115 let count = cache.models.len();
116 let cache_warning = cache_warning(&outcome);
117
118 if let Some(warning) = cache_warning.as_deref() {
119 eprintln!("warning: {warning}");
120 } else if !json {
121 eprintln!("done.");
122 }
123
124 if json {
125 let out = serde_json::json!({
126 "status": "ok",
127 "models_count": count,
128 "fetched_at": cache.fetched_at,
129 });
130 let mut out = out;
131 if let Some(warning) = cache_warning.as_deref() {
132 out["cache_warning"] = serde_json::json!(warning);
133 }
134 println!("{}", serde_json::to_string_pretty(&out).unwrap());
135 } else {
136 if cache_warning.is_some() {
137 println!(
138 "Using stale models cache with {} models in .mars/models-cache.json",
139 count
140 );
141 } else {
142 println!("Cached {} models in .mars/models-cache.json", count);
143 }
144 }
145
146 Ok(0)
147}
148
149fn run_list(args: &ListArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
150 let mars = mars_dir(ctx);
151 let ttl = models::load_models_cache_ttl(ctx);
152 let mode = models::resolve_refresh_mode(args.no_refresh_models);
153 let Some((cache, outcome)) = ensure_fresh_or_json_error(&mars, ttl, mode, json)? else {
154 return Ok(1);
155 };
156
157 if args.catalog {
158 return run_list_catalog(&cache, &outcome, json);
159 }
160
161 let merged = load_merged_aliases(ctx)?;
163 if args.all {
164 return run_list_all(&merged, &cache, &outcome, json);
165 }
166
167 let cache_warning = cache_warning(&outcome);
168 let mut diag = DiagnosticCollector::new();
169
170 let resolved = models::resolve_all(&merged, &cache, &mut diag);
171
172 let config_visibility = crate::config::load(&ctx.project_root)
174 .map(|c| c.settings.model_visibility)
175 .unwrap_or_default();
176
177 let visibility = if args.include.is_some() || args.exclude.is_some() {
178 crate::config::ModelVisibility {
179 include: args.include.clone(),
180 exclude: args.exclude.clone(),
181 }
182 } else {
183 config_visibility
184 };
185
186 let resolved = models::filter_by_visibility(resolved, &visibility);
187
188 if json {
189 let entries: Vec<serde_json::Value> = resolved
190 .values()
191 .map(|r| {
192 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
193 let mut obj = serde_json::json!({
194 "name": r.name,
195 "harness": r.harness,
196 "harness_source": r.harness_source,
197 "harness_candidates": r.harness_candidates,
198 "provider": r.provider,
199 "mode": mode,
200 "model_id": r.model_id,
201 "resolved_model": r.model_id,
202 "description": r.description,
203 });
204 if let Some(error) = unavailable_harness_error(r) {
205 obj["error"] = serde_json::json!(error);
206 }
207 if let Some(default_effort) = &r.default_effort {
208 obj["default_effort"] = serde_json::json!(default_effort);
209 }
210 if let Some(autocompact) = r.autocompact {
211 obj["autocompact"] = serde_json::json!(autocompact);
212 }
213 obj
214 })
215 .collect();
216 let mut out = serde_json::json!({
217 "aliases": entries,
218 "cache_available": cache.fetched_at.is_some(),
219 });
220 if let Some(warning) = cache_warning.as_deref() {
221 out["cache_warning"] = serde_json::json!(warning);
222 }
223 if let Some(diagnostics) = drain_diagnostics_json(&mut diag) {
224 out["diagnostics"] = diagnostics;
225 }
226 println!("{}", serde_json::to_string_pretty(&out).unwrap());
227 } else {
228 if let Some(warning) = cache_warning.as_deref() {
229 eprintln!("warning: {warning}");
230 }
231 println!(
233 "{:<12} {:<10} {:<14} {:<30} {}",
234 "ALIAS", "HARNESS", "MODE", "RESOLVED", "DESCRIPTION"
235 );
236 for r in resolved.values() {
237 if r.harness_source == HarnessSource::Unavailable {
238 continue;
239 }
240 let harness = r.harness.as_deref().unwrap_or("—");
241 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
242 let desc = r.description.clone().unwrap_or_default();
243 println!(
244 "{:<12} {:<10} {:<14} {:<30} {}",
245 r.name, harness, mode, r.model_id, desc
246 );
247 }
248 emit_text_diagnostics(&mut diag);
249 }
250
251 Ok(0)
252}
253
254#[derive(Debug, Clone)]
255struct ListModelEntry {
256 id: String,
257 provider: String,
258 release_date: Option<String>,
259 harness: Option<String>,
260 harness_source: HarnessSource,
261 harness_candidates: Vec<String>,
262 description: Option<String>,
263 matched_aliases: Vec<String>,
264}
265
266fn run_list_all(
267 merged: &IndexMap<String, ModelAlias>,
268 cache: &models::ModelsCache,
269 outcome: &models::RefreshOutcome,
270 json: bool,
271) -> Result<i32, MarsError> {
272 let cache_warning = cache_warning(outcome);
273 let models = collect_all_model_entries(merged, cache);
274
275 if json {
276 let entries: Vec<serde_json::Value> = models
277 .into_iter()
278 .map(|model| {
279 serde_json::json!({
280 "id": model.id,
281 "provider": model.provider,
282 "release_date": model.release_date,
283 "harness": model.harness,
284 "harness_source": model.harness_source,
285 "harness_candidates": model.harness_candidates,
286 "description": model.description,
287 "matched_aliases": model.matched_aliases,
288 })
289 })
290 .collect();
291 let mut out = serde_json::json!({
292 "models": entries,
293 "cache_available": cache.fetched_at.is_some(),
294 });
295 if let Some(warning) = cache_warning.as_deref() {
296 out["cache_warning"] = serde_json::json!(warning);
297 }
298 println!("{}", serde_json::to_string_pretty(&out).unwrap());
299 } else {
300 if let Some(warning) = cache_warning.as_deref() {
301 eprintln!("warning: {warning}");
302 }
303 println!(
304 "{:<10} {:<34} {:<12} {:<10} {}",
305 "PROVIDER", "MODEL ID", "RELEASE", "HARNESS", "ALIASES"
306 );
307 for model in models {
308 let release = model.release_date.as_deref().unwrap_or("—");
309 let harness = model.harness.as_deref().unwrap_or("—");
310 println!(
311 "{:<10} {:<34} {:<12} {:<10} {}",
312 model.provider,
313 model.id,
314 release,
315 harness,
316 model.matched_aliases.join(",")
317 );
318 }
319 }
320
321 Ok(0)
322}
323
324fn run_list_catalog(
325 cache: &models::ModelsCache,
326 outcome: &models::RefreshOutcome,
327 json: bool,
328) -> Result<i32, MarsError> {
329 let cache_warning = cache_warning(outcome);
330 let models = collect_catalog_model_entries(cache);
331
332 if json {
333 let entries: Vec<serde_json::Value> = models
334 .into_iter()
335 .map(|model| {
336 serde_json::json!({
337 "id": model.id,
338 "provider": model.provider,
339 "release_date": model.release_date,
340 "harness": model.harness,
341 "harness_source": model.harness_source,
342 "harness_candidates": model.harness_candidates,
343 "description": model.description,
344 })
345 })
346 .collect();
347 let mut out = serde_json::json!({
348 "models": entries,
349 "cache_available": cache.fetched_at.is_some(),
350 });
351 if let Some(warning) = cache_warning.as_deref() {
352 out["cache_warning"] = serde_json::json!(warning);
353 }
354 println!("{}", serde_json::to_string_pretty(&out).unwrap());
355 } else {
356 if let Some(warning) = cache_warning.as_deref() {
357 eprintln!("warning: {warning}");
358 }
359 println!(
360 "{:<10} {:<34} {:<12} {:<10}",
361 "PROVIDER", "MODEL ID", "RELEASE", "HARNESS"
362 );
363 for model in models {
364 let release = model.release_date.as_deref().unwrap_or("—");
365 let harness = model.harness.as_deref().unwrap_or("—");
366 println!(
367 "{:<10} {:<34} {:<12} {:<10}",
368 model.provider, model.id, release, harness
369 );
370 }
371 }
372
373 Ok(0)
374}
375
376fn collect_all_model_entries(
377 merged: &IndexMap<String, ModelAlias>,
378 cache: &models::ModelsCache,
379) -> Vec<ListModelEntry> {
380 let installed = models::harness::detect_installed_harnesses();
381 let mut by_model_id: IndexMap<String, ListModelEntry> = IndexMap::new();
382
383 for (alias_name, alias) in merged {
384 match &alias.spec {
385 ModelSpec::AutoResolve {
386 provider,
387 match_patterns,
388 exclude_patterns,
389 } => {
390 for matched in
391 models::auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
392 {
393 append_alias_match(&mut by_model_id, matched, &installed, alias_name);
394 }
395 }
396 ModelSpec::Pinned {
397 model, provider, ..
398 } => {
399 if let Some(matched) = cache
400 .models
401 .iter()
402 .find(|cache_model| cache_model.id == *model)
403 {
404 append_alias_match(&mut by_model_id, matched, &installed, alias_name);
405 } else {
406 append_pinned_alias_match(
407 &mut by_model_id,
408 model,
409 provider.as_deref(),
410 alias.description.as_deref(),
411 &installed,
412 alias_name,
413 );
414 }
415 }
416 ModelSpec::PinnedWithMatch {
417 model,
418 provider,
419 match_patterns,
420 exclude_patterns,
421 } => {
422 if let Some(matched) = cache
423 .models
424 .iter()
425 .find(|cache_model| cache_model.id == *model)
426 {
427 append_alias_match(&mut by_model_id, matched, &installed, alias_name);
428 } else {
429 append_pinned_alias_match(
430 &mut by_model_id,
431 model,
432 provider.as_deref(),
433 alias.description.as_deref(),
434 &installed,
435 alias_name,
436 );
437 }
438
439 let provider_for_discovery = provider
440 .as_deref()
441 .or_else(|| models::infer_provider_from_model_id(model));
442 if let Some(provider_for_discovery) = provider_for_discovery {
443 for matched in models::auto_resolve_all(
444 provider_for_discovery,
445 match_patterns,
446 exclude_patterns,
447 cache,
448 ) {
449 append_alias_match(&mut by_model_id, matched, &installed, alias_name);
450 }
451 }
452 }
453 }
454 }
455
456 let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
457 sort_list_model_entries(&mut out);
458 out
459}
460
461fn collect_catalog_model_entries(cache: &models::ModelsCache) -> Vec<ListModelEntry> {
462 let installed = models::harness::detect_installed_harnesses();
463 let mut out: Vec<ListModelEntry> = cache
464 .models
465 .iter()
466 .map(|model| model_entry_for_cached(model, &installed))
467 .collect();
468 sort_list_model_entries(&mut out);
469 out
470}
471
472fn append_alias_match(
473 by_model_id: &mut IndexMap<String, ListModelEntry>,
474 model: &models::CachedModel,
475 installed: &HashSet<String>,
476 alias_name: &str,
477) {
478 let entry = by_model_id
479 .entry(model.id.clone())
480 .or_insert_with(|| model_entry_for_cached(model, installed));
481
482 append_alias_name(entry, alias_name);
483}
484
485fn append_pinned_alias_match(
486 by_model_id: &mut IndexMap<String, ListModelEntry>,
487 model_id: &str,
488 provider: Option<&str>,
489 description: Option<&str>,
490 installed: &HashSet<String>,
491 alias_name: &str,
492) {
493 let entry = by_model_id
494 .entry(model_id.to_string())
495 .or_insert_with(|| model_entry_for_pinned(model_id, provider, description, installed));
496
497 append_alias_name(entry, alias_name);
498}
499
500fn append_alias_name(entry: &mut ListModelEntry, alias_name: &str) {
501 if !entry
502 .matched_aliases
503 .iter()
504 .any(|existing| existing == alias_name)
505 {
506 entry.matched_aliases.push(alias_name.to_string());
507 }
508}
509
510fn model_entry_for_cached(
511 model: &models::CachedModel,
512 installed: &HashSet<String>,
513) -> ListModelEntry {
514 let harness = models::harness::resolve_harness_for_provider(&model.provider, installed);
515 let harness_source = if harness.is_some() {
516 HarnessSource::AutoDetected
517 } else {
518 HarnessSource::Unavailable
519 };
520
521 ListModelEntry {
522 id: model.id.clone(),
523 provider: model.provider.clone(),
524 release_date: model.release_date.clone(),
525 harness,
526 harness_source,
527 harness_candidates: models::harness::harness_candidates_for_provider(&model.provider),
528 description: model.description.clone(),
529 matched_aliases: Vec::new(),
530 }
531}
532
533fn model_entry_for_pinned(
534 model_id: &str,
535 provider: Option<&str>,
536 description: Option<&str>,
537 installed: &HashSet<String>,
538) -> ListModelEntry {
539 let provider = provider
540 .map(str::to_string)
541 .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
542 .unwrap_or_else(|| "unknown".to_string());
543 let harness = models::harness::resolve_harness_for_provider(&provider, installed);
544 let harness_source = if harness.is_some() {
545 HarnessSource::AutoDetected
546 } else {
547 HarnessSource::Unavailable
548 };
549
550 ListModelEntry {
551 id: model_id.to_string(),
552 provider: provider.clone(),
553 release_date: None,
554 harness,
555 harness_source,
556 harness_candidates: models::harness::harness_candidates_for_provider(&provider),
557 description: description.map(str::to_string),
558 matched_aliases: Vec::new(),
559 }
560}
561
562fn sort_list_model_entries(entries: &mut [ListModelEntry]) {
563 entries.sort_by(|a, b| {
564 a.provider
565 .to_ascii_lowercase()
566 .cmp(&b.provider.to_ascii_lowercase())
567 .then_with(|| {
568 b.release_date
569 .as_deref()
570 .unwrap_or("")
571 .cmp(a.release_date.as_deref().unwrap_or(""))
572 })
573 .then_with(|| a.id.cmp(&b.id))
574 });
575}
576
577fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
578 let merged = load_merged_aliases(ctx)?;
579 let mars = mars_dir(ctx);
580 let ttl = models::load_models_cache_ttl(ctx);
581 let mode = models::resolve_refresh_mode(args.no_refresh_models);
582
583 let cache_result = ensure_fresh_or_json_error(&mars, ttl, mode, json)?;
585
586 if let Some((cache, outcome)) = &cache_result {
587 if let Some(alias) = merged.get(&args.name) {
589 return run_resolve_exact_alias(&args.name, alias, &merged, ctx, cache, outcome, json);
590 }
591
592 if let Some(resolved) = models::resolve_with_alias_prefix(&args.name, &merged, cache) {
594 return run_output_resolved(&args.name, &resolved, "alias_prefix", outcome, json);
595 }
596 }
597
598 let outcome = cache_result
600 .as_ref()
601 .map(|(_, o)| o.clone())
602 .unwrap_or(models::RefreshOutcome::Offline);
603 run_output_passthrough(&args.name, &outcome, json)
604}
605
606fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
607 let mut config = crate::config::load(&ctx.project_root)?;
608 config.models.insert(
609 args.name.clone(),
610 ModelAlias {
611 harness: Some(args.harness.clone()),
612 description: args.description.clone(),
613 default_effort: None,
614 autocompact: None,
615 spec: ModelSpec::Pinned {
616 model: args.model_id.clone(),
617 provider: None,
618 },
619 },
620 );
621 crate::config::save(&ctx.project_root, &config)?;
622
623 if json {
624 println!(
625 "{}",
626 serde_json::to_string_pretty(&serde_json::json!({
627 "status": "ok",
628 "alias": args.name,
629 "model": args.model_id,
630 "harness": args.harness,
631 }))
632 .unwrap()
633 );
634 } else {
635 println!(
636 "Added alias `{}` → {} (harness: {})",
637 args.name, args.model_id, args.harness
638 );
639 }
640
641 Ok(0)
642}
643
644fn ensure_fresh_or_json_error(
645 mars: &std::path::Path,
646 ttl: u32,
647 mode: models::RefreshMode,
648 json: bool,
649) -> Result<Option<(models::ModelsCache, models::RefreshOutcome)>, MarsError> {
650 match models::ensure_fresh(mars, ttl, mode) {
651 Ok(ok) => Ok(Some(ok)),
652 Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
653 println!(
654 "{}",
655 serde_json::to_string_pretty(&serde_json::json!({
656 "error": format!("{err}"),
657 }))
658 .unwrap()
659 );
660 Ok(None)
661 }
662 Err(err) => Err(err),
663 }
664}
665
666fn run_resolve_exact_alias(
667 name: &str,
668 alias: &ModelAlias,
669 merged: &IndexMap<String, ModelAlias>,
670 ctx: &MarsContext,
671 cache: &models::ModelsCache,
672 outcome: &models::RefreshOutcome,
673 json: bool,
674) -> Result<i32, MarsError> {
675 let cache_warning = cache_warning(outcome);
676 if let Some(warning) = cache_warning.as_deref()
677 && !json
678 {
679 eprintln!("warning: {warning}");
680 }
681
682 let source = determine_source(name, ctx)?;
683 let mut diag = DiagnosticCollector::new();
684 let resolved_entry = models::resolve_one(name, merged, cache, &mut diag);
685 let diagnostics = diag.drain();
686
687 if json {
688 if let Some(r) = resolved_entry.as_ref() {
689 let mut out = serde_json::json!({
690 "name": r.name,
691 "source": source,
692 "provider": r.provider,
693 "harness": r.harness,
694 "harness_source": r.harness_source,
695 "harness_candidates": r.harness_candidates,
696 "model_id": r.model_id,
697 "resolved_model": r.model_id,
698 "spec": format_spec(&alias.spec),
699 "description": r.description,
700 });
701 if let Some(error) = unavailable_harness_error(r) {
702 out["error"] = serde_json::json!(error);
703 }
704 if let Some(default_effort) = &r.default_effort {
705 out["default_effort"] = serde_json::json!(default_effort);
706 }
707 if let Some(autocompact) = r.autocompact {
708 out["autocompact"] = serde_json::json!(autocompact);
709 }
710 if let Some(warning) = cache_warning.as_deref() {
711 out["cache_warning"] = serde_json::json!(warning);
712 }
713 if !diagnostics.is_empty() {
714 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
715 }
716 println!("{}", serde_json::to_string_pretty(&out).unwrap());
717 } else {
718 let mut out = serde_json::json!({
719 "error": format!("alias `{}` did not resolve to a model ID", name),
720 });
721 if let Some(warning) = cache_warning.as_deref() {
722 out["cache_warning"] = serde_json::json!(warning);
723 }
724 if !diagnostics.is_empty() {
725 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
726 }
727 println!("{}", serde_json::to_string_pretty(&out).unwrap());
728 return Ok(1);
729 }
730 } else {
731 let Some(r) = resolved_entry.as_ref() else {
732 eprintln!("error: alias `{}` did not resolve to a model ID", name);
733 return Ok(1);
734 };
735 let harness = r.harness.as_deref().unwrap_or("—");
736 println!("Alias: {}", name);
737 println!("Source: {}", source);
738 println!(
739 "Harness: {} ({})",
740 harness,
741 harness_source_label(&r.harness_source)
742 );
743 println!("Provider: {}", r.provider);
744 match &alias.spec {
745 ModelSpec::Pinned { model, provider: _ } => {
746 println!("Mode: pinned");
747 println!("Model: {}", model);
748 }
749 ModelSpec::PinnedWithMatch {
750 model,
751 provider: _,
752 match_patterns,
753 exclude_patterns,
754 } => {
755 println!("Mode: pinned");
756 println!("Model: {}", model);
757 println!("Match: {}", match_patterns.join(", "));
758 if !exclude_patterns.is_empty() {
759 println!("Exclude: {}", exclude_patterns.join(", "));
760 }
761 println!("Resolved: {}", r.model_id);
762 }
763 ModelSpec::AutoResolve {
764 provider: _,
765 match_patterns,
766 exclude_patterns,
767 } => {
768 println!("Mode: auto-resolve");
769 println!("Match: {}", match_patterns.join(", "));
770 if !exclude_patterns.is_empty() {
771 println!("Exclude: {}", exclude_patterns.join(", "));
772 }
773 println!("Resolved: {}", r.model_id);
774 }
775 }
776 if let Some(error) = unavailable_harness_error(r) {
777 println!("Error: {}", error);
778 }
779 if let Some(desc) = &r.description {
780 println!("Desc: {}", desc);
781 }
782 emit_drained_text_diagnostics(&diagnostics);
783 }
784
785 Ok(0)
786}
787
788fn run_output_resolved(
789 name: &str,
790 resolved: &models::ResolvedAlias,
791 source: &str,
792 outcome: &models::RefreshOutcome,
793 json: bool,
794) -> Result<i32, MarsError> {
795 let cache_warning = cache_warning(outcome);
796 if let Some(warning) = cache_warning.as_deref()
797 && !json
798 {
799 eprintln!("warning: {warning}");
800 }
801
802 if json {
803 let mut out = serde_json::json!({
804 "name": name,
805 "source": source,
806 "provider": resolved.provider,
807 "harness": resolved.harness,
808 "harness_source": resolved.harness_source,
809 "harness_candidates": resolved.harness_candidates,
810 "model_id": resolved.model_id,
811 "resolved_model": resolved.model_id,
812 "description": resolved.description,
813 });
814 if let Some(error) = unavailable_harness_error(resolved) {
815 out["error"] = serde_json::json!(error);
816 }
817 if let Some(default_effort) = &resolved.default_effort {
818 out["default_effort"] = serde_json::json!(default_effort);
819 }
820 if let Some(autocompact) = resolved.autocompact {
821 out["autocompact"] = serde_json::json!(autocompact);
822 }
823 if let Some(warning) = cache_warning.as_deref() {
824 out["cache_warning"] = serde_json::json!(warning);
825 }
826 println!("{}", serde_json::to_string_pretty(&out).unwrap());
827 } else {
828 let harness = resolved.harness.as_deref().unwrap_or("—");
829 println!("Alias: {}", name);
830 println!("Source: {}", source);
831 println!(
832 "Harness: {} ({})",
833 harness,
834 harness_source_label(&resolved.harness_source)
835 );
836 println!("Provider: {}", resolved.provider);
837 println!("Resolved: {}", resolved.model_id);
838 if let Some(error) = unavailable_harness_error(resolved) {
839 println!("Error: {}", error);
840 }
841 if let Some(desc) = &resolved.description {
842 println!("Desc: {}", desc);
843 }
844 }
845
846 Ok(0)
847}
848
849fn run_output_passthrough(
850 name: &str,
851 outcome: &models::RefreshOutcome,
852 json: bool,
853) -> Result<i32, MarsError> {
854 if name.trim().is_empty() {
855 if json {
856 println!(
857 "{}",
858 serde_json::to_string_pretty(&serde_json::json!({
859 "error": "model name cannot be empty"
860 }))
861 .unwrap()
862 );
863 } else {
864 eprintln!("error: model name cannot be empty");
865 }
866 return Ok(1);
867 }
868
869 let cache_warning = cache_warning(outcome);
870 if let Some(warning) = cache_warning.as_deref()
871 && !json
872 {
873 eprintln!("warning: {warning}");
874 }
875
876 let installed = models::harness::detect_installed_harnesses();
877 let guessed_provider = models::infer_provider_from_model_id(name).map(str::to_string);
878 let harness = guessed_provider
879 .as_deref()
880 .and_then(|p| models::harness::resolve_harness_for_provider(p, &installed));
881 let harness_source = if harness.is_some() {
882 "pattern_guess"
883 } else {
884 "unavailable"
885 };
886 let harness_candidates = guessed_provider
887 .as_deref()
888 .map(models::harness::harness_candidates_for_provider)
889 .unwrap_or_default();
890
891 let warning = format!(
892 "model '{}' not found in catalog, passing through to harness",
893 name
894 );
895
896 if json {
897 let mut out = serde_json::json!({
898 "name": name,
899 "source": "passthrough",
900 "model_id": name,
901 "resolved_model": name,
902 "provider": guessed_provider,
903 "harness": harness,
904 "harness_source": harness_source,
905 "harness_candidates": harness_candidates,
906 "description": serde_json::Value::Null,
907 "warning": warning,
908 });
909 if let Some(warning) = cache_warning.as_deref() {
910 out["cache_warning"] = serde_json::json!(warning);
911 }
912 println!("{}", serde_json::to_string_pretty(&out).unwrap());
913 } else {
914 eprintln!("warning: {}", warning);
915 let h = harness.as_deref().unwrap_or("—");
916 println!("Model: {}", name);
917 println!("Source: passthrough");
918 println!("Harness: {} ({})", h, harness_source);
919 if let Some(provider) = guessed_provider {
920 println!("Provider: {}", provider);
921 }
922 if !harness_candidates.is_empty() {
923 println!("Candidates: {}", harness_candidates.join(", "));
924 }
925 }
926
927 Ok(0)
928}
929
930fn load_merged_aliases(
936 ctx: &MarsContext,
937) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
938 let mut merged = models::builtin_aliases();
940
941 let mars_dir = ctx.project_root.join(".mars");
943 let merged_path = mars_dir.join("models-merged.json");
944 if let Ok(content) = std::fs::read_to_string(&merged_path)
945 && let Ok(cached) = serde_json::from_str::<IndexMap<String, ModelAlias>>(&content)
946 {
947 for (name, alias) in cached {
948 merged.insert(name, alias);
949 }
950 }
951
952 if let Ok(config) = crate::config::load(&ctx.project_root) {
954 for (name, alias) in &config.models {
955 merged.insert(name.clone(), alias.clone());
956 }
957 }
958
959 Ok(merged)
960}
961
962fn determine_source(name: &str, ctx: &MarsContext) -> Result<String, MarsError> {
964 let config = match crate::config::load(&ctx.project_root) {
965 Ok(c) => c,
966 Err(_) => return Ok("unknown".to_string()),
967 };
968
969 if config.models.contains_key(name) {
970 return Ok("consumer (mars.toml)".to_string());
971 }
972
973 Ok("dependency".to_string())
974}
975
976fn format_spec(spec: &ModelSpec) -> serde_json::Value {
977 match spec {
978 ModelSpec::Pinned { model, provider } => {
979 let mut out = serde_json::json!({ "mode": "pinned", "model": model });
980 if let Some(provider) = provider {
981 out["provider"] = serde_json::json!(provider);
982 }
983 out
984 }
985 ModelSpec::PinnedWithMatch {
986 model,
987 provider,
988 match_patterns,
989 exclude_patterns,
990 } => {
991 let mut out = serde_json::json!({
992 "mode": "pinned",
993 "model": model,
994 "match": match_patterns,
995 "exclude": exclude_patterns,
996 });
997 if let Some(provider) = provider {
998 out["provider"] = serde_json::json!(provider);
999 }
1000 out
1001 }
1002 ModelSpec::AutoResolve {
1003 provider,
1004 match_patterns,
1005 exclude_patterns,
1006 } => {
1007 serde_json::json!({
1008 "mode": "auto-resolve",
1009 "provider": provider,
1010 "match": match_patterns,
1011 "exclude": exclude_patterns,
1012 })
1013 }
1014 }
1015}
1016
1017fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
1018 match spec {
1019 Some(ModelSpec::Pinned { .. }) | Some(ModelSpec::PinnedWithMatch { .. }) => "pinned",
1020 Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
1021 None => "unknown",
1022 }
1023}
1024
1025fn harness_source_label(source: &HarnessSource) -> &'static str {
1026 match source {
1027 HarnessSource::Explicit => "explicit",
1028 HarnessSource::AutoDetected => "auto-detected",
1029 HarnessSource::Unavailable => "unavailable",
1030 }
1031}
1032
1033fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
1034 if resolved.harness_source != HarnessSource::Unavailable {
1035 return None;
1036 }
1037 if let Some(h) = &resolved.harness {
1038 Some(format!("Harness '{}' is not installed", h))
1039 } else {
1040 Some(format!(
1041 "No installed harness for provider '{}'. Install one of: {}",
1042 resolved.provider,
1043 resolved.harness_candidates.join(", ")
1044 ))
1045 }
1046}
1047
1048fn stale_warning(reason: &str) -> String {
1049 format!("models cache refresh failed: {reason}; using stale cache")
1050}
1051
1052fn cache_warning(outcome: &models::RefreshOutcome) -> Option<String> {
1053 match outcome {
1054 models::RefreshOutcome::StaleFallback { reason } => Some(stale_warning(reason)),
1055 _ => None,
1056 }
1057}
1058
1059fn diagnostics_to_json_entries(diagnostics: &[Diagnostic]) -> Vec<serde_json::Value> {
1060 diagnostics
1061 .iter()
1062 .map(|diagnostic| {
1063 serde_json::json!({
1064 "level": diagnostic_level_label(diagnostic.level),
1065 "code": diagnostic.code,
1066 "message": diagnostic.message,
1067 "context": diagnostic.context,
1068 })
1069 })
1070 .collect()
1071}
1072
1073fn drain_diagnostics_json(diag: &mut DiagnosticCollector) -> Option<serde_json::Value> {
1074 let diagnostics = diag.drain();
1075 if diagnostics.is_empty() {
1076 None
1077 } else {
1078 Some(serde_json::json!(diagnostics_to_json_entries(&diagnostics)))
1079 }
1080}
1081
1082fn emit_drained_text_diagnostics(diagnostics: &[Diagnostic]) {
1083 for diagnostic in diagnostics {
1084 let label = diagnostic_level_label(diagnostic.level);
1085 eprintln!("{label}: {}", diagnostic.message);
1086 }
1087}
1088
1089fn emit_text_diagnostics(diag: &mut DiagnosticCollector) {
1090 let diagnostics = diag.drain();
1091 emit_drained_text_diagnostics(&diagnostics);
1092}
1093
1094fn diagnostic_level_label(level: DiagnosticLevel) -> &'static str {
1095 match level {
1096 DiagnosticLevel::Warning => "warning",
1097 DiagnosticLevel::Info => "info",
1098 }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104 use clap::Parser;
1105 use indexmap::IndexMap;
1106 use tempfile::TempDir;
1107
1108 fn write_mars_toml(temp: &TempDir, contents: &str) {
1109 std::fs::write(temp.path().join("mars.toml"), contents).unwrap();
1110 }
1111
1112 fn normalized_exit_code(result: Result<i32, MarsError>) -> i32 {
1113 match result {
1114 Ok(code) => code,
1115 Err(err) => err.exit_code(),
1116 }
1117 }
1118
1119 #[test]
1120 fn list_args_parses_no_refresh_models() {
1121 let args = ListArgs::try_parse_from(["mars", "--no-refresh-models"]).unwrap();
1122 assert!(args.no_refresh_models);
1123 }
1124
1125 #[test]
1126 fn list_args_parses_catalog() {
1127 let args = ListArgs::try_parse_from(["mars", "--catalog"]).unwrap();
1128 assert!(args.catalog);
1129 }
1130
1131 #[test]
1132 fn list_all_and_catalog_conflict() {
1133 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--catalog"]);
1134 assert!(parsed.is_err());
1135 }
1136
1137 #[test]
1138 fn list_all_and_include_conflict() {
1139 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--include", "opus"]);
1140 assert!(parsed.is_err());
1141 }
1142
1143 #[test]
1144 fn list_catalog_and_include_conflict() {
1145 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--catalog", "--include", "opus"]);
1146 assert!(parsed.is_err());
1147 }
1148
1149 #[test]
1150 fn resolve_alias_args_parses_no_refresh_models() {
1151 let args =
1152 ResolveAliasArgs::try_parse_from(["mars", "opus", "--no-refresh-models"]).unwrap();
1153 assert!(args.no_refresh_models);
1154 }
1155
1156 #[test]
1157 fn list_no_refresh_without_cache_is_non_zero() {
1158 let temp = TempDir::new().unwrap();
1159 write_mars_toml(&temp, "[settings]\n");
1160 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1161 let args = ModelsArgs::try_parse_from(["mars", "list", "--no-refresh-models"]).unwrap();
1162
1163 let exit = normalized_exit_code(run(&args, &ctx, false));
1164 assert_ne!(exit, 0);
1165 }
1166
1167 #[test]
1168 fn resolve_no_refresh_without_cache_is_non_zero() {
1169 let temp = TempDir::new().unwrap();
1170 write_mars_toml(
1171 &temp,
1172 r#"[settings]
1173
1174[models.opus]
1175harness = "claude"
1176model = "claude-opus-4-6"
1177"#,
1178 );
1179 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1180 let args =
1181 ModelsArgs::try_parse_from(["mars", "resolve", "opus", "--no-refresh-models"]).unwrap();
1182
1183 let exit = normalized_exit_code(run(&args, &ctx, false));
1184 assert_ne!(exit, 0);
1185 }
1186
1187 #[test]
1188 fn alias_updates_existing_model_entry() {
1189 let temp = TempDir::new().unwrap();
1190 write_mars_toml(
1191 &temp,
1192 r#"[settings]
1193
1194[models.fast]
1195harness = "claude"
1196model = "claude-3-5-sonnet"
1197description = "Old alias"
1198"#,
1199 );
1200 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1201
1202 let args = AddAliasArgs {
1203 name: "fast".to_string(),
1204 model_id: "gpt-5.3-codex".to_string(),
1205 harness: "codex".to_string(),
1206 description: Some("Updated alias".to_string()),
1207 };
1208
1209 let exit = run_alias(&args, &ctx, false).unwrap();
1210 assert_eq!(exit, 0);
1211
1212 let config = crate::config::load(temp.path()).unwrap();
1213 assert_eq!(config.models.len(), 1);
1214
1215 let alias = config.models.get("fast").unwrap();
1216 assert_eq!(alias.harness.as_deref(), Some("codex"));
1217 assert_eq!(alias.description.as_deref(), Some("Updated alias"));
1218 match &alias.spec {
1219 ModelSpec::Pinned { model, provider } => {
1220 assert_eq!(model, "gpt-5.3-codex");
1221 assert_eq!(provider, &None);
1222 }
1223 _ => panic!("expected pinned alias"),
1224 }
1225 }
1226
1227 fn auto_alias(
1228 provider: &str,
1229 match_patterns: &[&str],
1230 exclude_patterns: &[&str],
1231 ) -> ModelAlias {
1232 ModelAlias {
1233 harness: None,
1234 description: None,
1235 default_effort: None,
1236 autocompact: None,
1237 spec: ModelSpec::AutoResolve {
1238 provider: provider.to_string(),
1239 match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
1240 exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
1241 },
1242 }
1243 }
1244
1245 fn pinned_with_match_alias(
1246 model: &str,
1247 provider: &str,
1248 match_patterns: &[&str],
1249 exclude_patterns: &[&str],
1250 ) -> ModelAlias {
1251 ModelAlias {
1252 harness: None,
1253 description: None,
1254 default_effort: None,
1255 autocompact: None,
1256 spec: ModelSpec::PinnedWithMatch {
1257 model: model.to_string(),
1258 provider: Some(provider.to_string()),
1259 match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
1260 exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
1261 },
1262 }
1263 }
1264
1265 fn pinned_alias(model: &str) -> ModelAlias {
1266 ModelAlias {
1267 harness: None,
1268 description: None,
1269 default_effort: None,
1270 autocompact: None,
1271 spec: ModelSpec::Pinned {
1272 model: model.to_string(),
1273 provider: None,
1274 },
1275 }
1276 }
1277
1278 fn pinned_alias_with_provider(model: &str, provider: &str) -> ModelAlias {
1279 ModelAlias {
1280 harness: None,
1281 description: None,
1282 default_effort: None,
1283 autocompact: None,
1284 spec: ModelSpec::Pinned {
1285 model: model.to_string(),
1286 provider: Some(provider.to_string()),
1287 },
1288 }
1289 }
1290
1291 fn cached_model(id: &str, provider: &str, release_date: Option<&str>) -> models::CachedModel {
1292 models::CachedModel {
1293 id: id.to_string(),
1294 provider: provider.to_string(),
1295 release_date: release_date.map(|value| value.to_string()),
1296 description: Some(format!("desc-{id}")),
1297 context_window: None,
1298 max_output: None,
1299 }
1300 }
1301
1302 fn cache(models: Vec<models::CachedModel>) -> models::ModelsCache {
1303 models::ModelsCache {
1304 models,
1305 fetched_at: Some("123".to_string()),
1306 }
1307 }
1308
1309 #[test]
1310 fn list_all_shows_multiple_per_alias() {
1311 let mut merged = IndexMap::new();
1312 merged.insert(
1313 "opus".to_string(),
1314 auto_alias("Anthropic", &["claude-opus-*"], &[]),
1315 );
1316
1317 let models_cache = cache(vec![
1318 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1319 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-01")),
1320 ]);
1321
1322 let rows = collect_all_model_entries(&merged, &models_cache);
1323 assert_eq!(rows.len(), 2);
1324 assert_eq!(rows[0].id, "claude-opus-4-7");
1325 assert_eq!(rows[1].id, "claude-opus-4-6");
1326 }
1327
1328 #[test]
1329 fn list_all_includes_matched_aliases_with_dedup() {
1330 let mut merged = IndexMap::new();
1331 merged.insert(
1332 "opus".to_string(),
1333 auto_alias("Anthropic", &["claude-opus-*"], &[]),
1334 );
1335 merged.insert(
1336 "legacy".to_string(),
1337 auto_alias("Anthropic", &["*4-6"], &[]),
1338 );
1339
1340 let models_cache = cache(vec![cached_model(
1341 "claude-opus-4-6",
1342 "Anthropic",
1343 Some("2026-02-05"),
1344 )]);
1345
1346 let rows = collect_all_model_entries(&merged, &models_cache);
1347 assert_eq!(rows.len(), 1);
1348 assert_eq!(rows[0].id, "claude-opus-4-6");
1349 assert_eq!(rows[0].matched_aliases, vec!["opus", "legacy"]);
1350 }
1351
1352 #[test]
1353 fn list_all_includes_pinned_cache_entries() {
1354 let mut merged = IndexMap::new();
1355 merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
1356
1357 let models_cache = cache(vec![cached_model(
1358 "gpt-5.3-codex",
1359 "OpenAI",
1360 Some("2026-01-01"),
1361 )]);
1362 let rows = collect_all_model_entries(&merged, &models_cache);
1363 assert_eq!(rows.len(), 1);
1364 assert_eq!(rows[0].id, "gpt-5.3-codex");
1365 assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
1366 }
1367
1368 #[test]
1369 fn list_all_includes_pinned_cache_miss_entries() {
1370 let mut merged = IndexMap::new();
1371 merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
1372
1373 let models_cache = cache(Vec::new());
1374 let rows = collect_all_model_entries(&merged, &models_cache);
1375 assert_eq!(rows.len(), 1);
1376 assert_eq!(rows[0].id, "gpt-5.3-codex");
1377 assert!(rows[0].provider.eq_ignore_ascii_case("openai"));
1378 assert_eq!(rows[0].release_date, None);
1379 assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
1380 }
1381
1382 #[test]
1383 fn list_all_uses_declared_provider_for_pinned_cache_miss_entries() {
1384 let mut merged = IndexMap::new();
1385 merged.insert(
1386 "custom".to_string(),
1387 pinned_alias_with_provider("custom-model-id", "Anthropic"),
1388 );
1389
1390 let models_cache = cache(Vec::new());
1391 let rows = collect_all_model_entries(&merged, &models_cache);
1392 assert_eq!(rows.len(), 1);
1393 assert_eq!(rows[0].id, "custom-model-id");
1394 assert_eq!(rows[0].provider, "Anthropic");
1395 assert_eq!(rows[0].release_date, None);
1396 assert_eq!(rows[0].matched_aliases, vec!["custom"]);
1397 }
1398
1399 #[test]
1400 fn list_all_includes_unavailable_harness_entries() {
1401 let mut merged = IndexMap::new();
1402 merged.insert("x".to_string(), auto_alias("Unknown", &["x-*"], &[]));
1403 let models_cache = cache(vec![cached_model("x-1", "Unknown", Some("2026-01-01"))]);
1404
1405 let rows = collect_all_model_entries(&merged, &models_cache);
1406 assert_eq!(rows.len(), 1);
1407 assert_eq!(rows[0].harness, None);
1408 assert_eq!(rows[0].harness_source, HarnessSource::Unavailable);
1409 assert!(rows[0].harness_candidates.is_empty());
1410 }
1411
1412 #[test]
1413 fn list_catalog_shows_all_cache_sorted() {
1414 let models_cache = cache(vec![
1415 cached_model("gpt-5", "OpenAI", Some("2025-06-01")),
1416 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1417 cached_model("claude-sonnet-4-5", "Anthropic", Some("2025-08-01")),
1418 ]);
1419
1420 let rows = collect_catalog_model_entries(&models_cache);
1421 assert_eq!(rows.len(), 3);
1422 assert_eq!(rows[0].id, "claude-opus-4-6");
1423 assert_eq!(rows[1].id, "claude-sonnet-4-5");
1424 assert_eq!(rows[2].id, "gpt-5");
1425 }
1426
1427 #[test]
1428 fn list_all_includes_pinned_with_match_discovery_candidates() {
1429 let mut merged = IndexMap::new();
1430 merged.insert(
1431 "opus".to_string(),
1432 pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
1433 );
1434 let models_cache = cache(vec![
1435 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
1436 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1437 ]);
1438
1439 let rows = collect_all_model_entries(&merged, &models_cache);
1440 assert_eq!(rows.len(), 2);
1441 assert_eq!(rows[0].id, "claude-opus-4-7");
1442 assert_eq!(rows[1].id, "claude-opus-4-6");
1443 assert_eq!(rows[0].matched_aliases, vec!["opus"]);
1444 assert_eq!(rows[1].matched_aliases, vec!["opus"]);
1445 }
1446
1447 #[test]
1448 fn resolve_pinned_with_match_uses_model_field() {
1449 let mut merged = IndexMap::new();
1450 merged.insert(
1451 "opus".to_string(),
1452 pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
1453 );
1454 let models_cache = cache(vec![
1455 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
1456 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1457 ]);
1458 let mut diag = DiagnosticCollector::new();
1459 let resolved = models::resolve_one("opus", &merged, &models_cache, &mut diag).unwrap();
1460 assert_eq!(resolved.model_id, "claude-opus-4-6");
1461 assert!(diag.drain().is_empty());
1462 }
1463}