1#![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#[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,
24 Resolve(ResolveAliasArgs),
26 Alias(AddAliasArgs),
28}
29
30#[derive(Debug, Parser)]
31pub struct ResolveAliasArgs {
32 pub name: String,
34}
35
36#[derive(Debug, Parser)]
37pub struct AddAliasArgs {
38 pub name: String,
40 pub model_id: String,
42 #[arg(long, default_value = "claude")]
44 pub harness: String,
45 #[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 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 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 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 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
236
237 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 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
275fn load_merged_aliases(
281 ctx: &MarsContext,
282) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
283 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 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 for (name, alias) in &config.models {
307 merged.insert(name.clone(), alias.clone());
308 }
309
310 Ok(merged)
311}
312
313fn 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 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!("{secs}")
352}