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}
36
37#[derive(Debug, Parser)]
38pub struct ResolveAliasArgs {
39 pub name: String,
41}
42
43#[derive(Debug, Parser)]
44pub struct AddAliasArgs {
45 pub name: String,
47 pub model_id: String,
49 #[arg(long, default_value = "claude")]
51 pub harness: String,
52 #[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 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 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 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 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
269
270 let harness = Some(args.harness.clone());
271
272 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 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
312fn load_merged_aliases(
318 ctx: &MarsContext,
319) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
320 let mut merged = models::builtin_aliases();
322
323 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 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
344fn 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 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!("{secs}")
420}