1use crate::config::{DependencyEntry, FilterConfig};
4use crate::error::{ConfigError, MarsError};
5use crate::source::parse;
6use crate::sync::{
7 ConfigMutation, DependencyUpsertChange, ResolutionMode, SyncOptions, SyncRequest,
8};
9use crate::types::{ItemName, SourceName, SourceSubpath};
10
11use super::output;
12
13#[derive(Debug, clap::Args)]
15pub struct AddArgs {
16 #[arg(required = true)]
18 pub sources: Vec<String>,
19
20 #[arg(long)]
22 pub subpath: Option<String>,
23
24 #[arg(long, value_delimiter = ',')]
26 pub agents: Vec<String>,
27
28 #[arg(long, value_delimiter = ',')]
30 pub skills: Vec<String>,
31
32 #[arg(long, value_delimiter = ',')]
34 pub exclude: Vec<String>,
35
36 #[arg(long)]
38 pub only_skills: bool,
39
40 #[arg(long)]
42 pub only_agents: bool,
43}
44
45#[derive(Debug)]
47struct ParsedDependency {
48 name: SourceName,
49 entry: DependencyEntry,
50}
51
52pub fn run(args: &AddArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
54 let has_filters = !args.agents.is_empty()
56 || !args.skills.is_empty()
57 || !args.exclude.is_empty()
58 || args.only_skills
59 || args.only_agents;
60
61 if has_filters && args.sources.len() > 1 {
62 return Err(MarsError::InvalidRequest {
63 message: "filters may only be used when adding exactly one source".to_string(),
64 });
65 }
66 if args.subpath.is_some() && args.sources.len() != 1 {
67 return Err(MarsError::InvalidRequest {
68 message: "--subpath requires exactly one source argument".to_string(),
69 });
70 }
71
72 let filter_config = build_filter_config(args);
74 crate::config::validate_filter(&filter_config, "cli")?;
75
76 let mutations: Vec<(SourceName, DependencyEntry)> = args
78 .sources
79 .iter()
80 .map(|source| {
81 let parsed = parse_dependency_specifier(source, args.subpath.as_deref())?;
82 let entry = DependencyEntry {
83 url: parsed.entry.url,
84 path: parsed.entry.path,
85 subpath: parsed.entry.subpath,
86 version: parsed.entry.version,
87 filter: filter_config.clone(),
88 };
89 Ok((parsed.name, entry))
90 })
91 .collect::<Result<Vec<_>, MarsError>>()?;
92
93 if mutations.len() == 1 {
96 let (name, entry) = mutations.into_iter().next().unwrap();
97
98 let request = SyncRequest {
99 resolution: ResolutionMode::Normal,
100 mutation: Some(ConfigMutation::UpsertDependency {
101 name: name.clone(),
102 entry,
103 }),
104 options: SyncOptions {
105 force: false,
106 dry_run: false,
107 frozen: false,
108 refresh_models: false,
109 no_refresh_models: false,
110 },
111 };
112
113 let report = crate::sync::execute(ctx, &request)?;
114
115 if !json {
116 print_dependency_messages(&report.dependency_changes);
117 }
118
119 output::print_sync_report(&report, json, true);
120 return if report.has_conflicts() { Ok(1) } else { Ok(0) };
121 }
122
123 let request = SyncRequest {
125 resolution: ResolutionMode::Normal,
126 mutation: Some(ConfigMutation::BatchUpsert(mutations)),
127 options: SyncOptions {
128 force: false,
129 dry_run: false,
130 frozen: false,
131 refresh_models: false,
132 no_refresh_models: false,
133 },
134 };
135
136 let report = crate::sync::execute(ctx, &request)?;
137
138 if !json {
139 print_dependency_messages(&report.dependency_changes);
140 }
141
142 output::print_sync_report(&report, json, true);
143 if report.has_conflicts() { Ok(1) } else { Ok(0) }
144}
145
146fn build_filter_config(args: &AddArgs) -> FilterConfig {
148 FilterConfig {
149 agents: if args.agents.is_empty() {
150 None
151 } else {
152 Some(
153 args.agents
154 .iter()
155 .map(|v| ItemName::from(v.as_str()))
156 .collect(),
157 )
158 },
159 skills: if args.skills.is_empty() {
160 None
161 } else {
162 Some(
163 args.skills
164 .iter()
165 .map(|v| ItemName::from(v.as_str()))
166 .collect(),
167 )
168 },
169 exclude: if args.exclude.is_empty() {
170 None
171 } else {
172 Some(
173 args.exclude
174 .iter()
175 .map(|v| ItemName::from(v.as_str()))
176 .collect(),
177 )
178 },
179 rename: None,
180 only_skills: args.only_skills,
181 only_agents: args.only_agents,
182 }
183}
184
185fn parse_dependency_specifier(
194 spec: &str,
195 explicit_subpath: Option<&str>,
196) -> Result<ParsedDependency, MarsError> {
197 let parsed = parse::parse(spec).map_err(|e| {
198 MarsError::Config(ConfigError::Invalid {
199 message: e.to_string(),
200 })
201 })?;
202
203 let explicit_subpath = explicit_subpath
204 .map(|value| {
205 SourceSubpath::new(value).map_err(|e| {
206 MarsError::Config(ConfigError::Invalid {
207 message: e.to_string(),
208 })
209 })
210 })
211 .transpose()?;
212 let subpath = merge_subpath(parsed.subpath.clone(), explicit_subpath)?;
213 let name = derive_dependency_name(&parsed, subpath.as_ref())?;
214
215 Ok(ParsedDependency {
216 name: SourceName::from(name),
217 entry: DependencyEntry {
218 url: parsed.url,
219 path: parsed.path,
220 subpath,
221 version: parsed.version,
222 filter: FilterConfig::default(),
223 },
224 })
225}
226
227fn merge_subpath(
228 parsed_subpath: Option<SourceSubpath>,
229 explicit_subpath: Option<SourceSubpath>,
230) -> Result<Option<SourceSubpath>, MarsError> {
231 match (parsed_subpath, explicit_subpath) {
232 (Some(parsed), Some(explicit)) if parsed != explicit => Err(MarsError::InvalidRequest {
233 message: format!(
234 "conflicting subpath input: source provides `{parsed}` but --subpath provides `{explicit}`"
235 ),
236 }),
237 (Some(parsed), Some(_)) => Ok(Some(parsed)),
238 (Some(parsed), None) => Ok(Some(parsed)),
239 (None, Some(explicit)) => Ok(Some(explicit)),
240 (None, None) => Ok(None),
241 }
242}
243
244fn derive_dependency_name(
245 parsed: &parse::ParsedSourceSpec,
246 subpath: Option<&SourceSubpath>,
247) -> Result<String, MarsError> {
248 let root_name = parsed.name.split('/').next().ok_or_else(|| {
249 MarsError::Config(ConfigError::Invalid {
250 message: format!("cannot derive dependency name from `{}`", parsed.raw),
251 })
252 })?;
253
254 Ok(match subpath {
255 Some(subpath) => format!("{root_name}/{}", subpath.as_str()),
256 None => root_name.to_string(),
257 })
258}
259
260fn print_dependency_messages(changes: &[DependencyUpsertChange]) {
261 for change in changes {
262 if change.already_exists {
263 output::print_warn(&format!(
264 "dependency `{}` already exists — updated",
265 change.name
266 ));
267 if let Some(old_filter) = &change.old_filter
268 && old_filter != &change.new_filter
269 {
270 output::print_info(&format!(
271 "filters changed: {} → {}",
272 format_filter(old_filter),
273 format_filter(&change.new_filter)
274 ));
275 }
276 } else {
277 output::print_info(&format!("added dependency `{}`", change.name));
278 }
279 }
280}
281
282fn format_filter(filter: &FilterConfig) -> String {
283 if filter.only_skills {
284 return "only_skills=true".to_string();
285 }
286 if filter.only_agents {
287 return "only_agents=true".to_string();
288 }
289
290 let mut parts = Vec::new();
291 if let Some(agents) = &filter.agents {
292 parts.push(format!("agents=[{}]", format_item_names(agents)));
293 }
294 if let Some(skills) = &filter.skills {
295 parts.push(format!("skills=[{}]", format_item_names(skills)));
296 }
297 if let Some(exclude) = &filter.exclude {
298 parts.push(format!("exclude=[{}]", format_item_names(exclude)));
299 }
300
301 if parts.is_empty() {
302 "all".to_string()
303 } else {
304 parts.join(", ")
305 }
306}
307
308fn format_item_names(items: &[ItemName]) -> String {
309 items
310 .iter()
311 .map(|item| item.to_string())
312 .collect::<Vec<_>>()
313 .join(",")
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::sync::DependencyUpsertChange;
320 use std::path::Path;
321
322 #[test]
323 fn parse_github_shorthand() {
324 let parsed = parse_dependency_specifier("meridian-flow/meridian-base", None).unwrap();
325 assert_eq!(parsed.name, "meridian-base");
326 assert_eq!(
327 parsed.entry.url.as_deref(),
328 Some("https://github.com/meridian-flow/meridian-base")
329 );
330 assert!(parsed.entry.path.is_none());
331 assert!(parsed.entry.version.is_none());
332 }
333
334 #[test]
335 fn parse_github_shorthand_with_version() {
336 let parsed =
337 parse_dependency_specifier("meridian-flow/meridian-base@v0.5.0", None).unwrap();
338 assert_eq!(parsed.name, "meridian-base");
339 assert_eq!(
340 parsed.entry.url.as_deref(),
341 Some("https://github.com/meridian-flow/meridian-base")
342 );
343 assert_eq!(parsed.entry.version.as_deref(), Some("v0.5.0"));
344 }
345
346 #[test]
347 fn parse_full_url() {
348 let parsed =
349 parse_dependency_specifier("github.com/meridian-flow/meridian-dev-workflow@v2", None)
350 .unwrap();
351 assert_eq!(parsed.name, "meridian-dev-workflow");
352 assert_eq!(
353 parsed.entry.url.as_deref(),
354 Some("https://github.com/meridian-flow/meridian-dev-workflow")
355 );
356 assert_eq!(parsed.entry.version.as_deref(), Some("v2"));
357 }
358
359 #[test]
360 fn parse_https_url() {
361 let parsed =
362 parse_dependency_specifier("https://github.com/someone/cool-agents.git", None).unwrap();
363 assert_eq!(parsed.name, "cool-agents");
364 assert_eq!(
365 parsed.entry.url.as_deref(),
366 Some("https://github.com/someone/cool-agents")
367 );
368 }
369
370 #[test]
371 fn parse_ssh_url() {
372 let parsed =
373 parse_dependency_specifier("git@github.com:someone/cool-agents.git", None).unwrap();
374 assert_eq!(parsed.name, "cool-agents");
375 assert_eq!(
376 parsed.entry.url.as_deref(),
377 Some("git@github.com:someone/cool-agents.git")
378 );
379 assert!(parsed.entry.version.is_none());
380 }
381
382 #[test]
383 fn parse_ssh_url_keeps_at_suffix_in_path() {
384 let parsed =
385 parse_dependency_specifier("git@github.com:someone/cool-agents.git@v2", None).unwrap();
386 assert_eq!(parsed.name, "cool-agents");
387 assert_eq!(
388 parsed.entry.url.as_deref(),
389 Some("git@github.com:someone/cool-agents.git")
390 );
391 assert_eq!(parsed.entry.version.as_deref(), Some("v2"));
392 }
393
394 #[test]
395 fn parse_local_path_relative() {
396 let parsed = parse_dependency_specifier("./my-agents", None).unwrap();
397 assert_eq!(parsed.name, "my-agents");
398 assert!(parsed.entry.url.is_none());
399 assert_eq!(parsed.entry.path.as_deref(), Some(Path::new("./my-agents")));
400 }
401
402 #[test]
403 fn parse_local_path_parent() {
404 let parsed = parse_dependency_specifier("../meridian-dev-workflow", None).unwrap();
405 assert_eq!(parsed.name, "meridian-dev-workflow");
406 assert!(parsed.entry.url.is_none());
407 assert_eq!(
408 parsed.entry.path.as_deref(),
409 Some(Path::new("../meridian-dev-workflow"))
410 );
411 }
412
413 #[test]
414 fn parse_local_path_absolute() {
415 let parsed = parse_dependency_specifier("/home/dev/agents", None).unwrap();
416 assert_eq!(parsed.name, "agents");
417 assert!(parsed.entry.url.is_none());
418 assert_eq!(
419 parsed.entry.path.as_deref(),
420 Some(Path::new("/home/dev/agents"))
421 );
422 }
423
424 #[test]
425 fn parse_source_embedded_subpath() {
426 let parsed = parse_dependency_specifier("owner/repo/plugins/foo", None).unwrap();
427 assert_eq!(parsed.name, "repo/plugins/foo");
428 assert_eq!(
429 parsed.entry.subpath.as_ref().map(SourceSubpath::as_str),
430 Some("plugins/foo")
431 );
432 }
433
434 #[test]
435 fn parse_explicit_subpath_merges_when_source_has_none() {
436 let parsed =
437 parse_dependency_specifier("gitlab:group/subgroup/repo", Some("plugins/foo")).unwrap();
438 assert_eq!(parsed.name, "repo/plugins/foo");
439 assert_eq!(
440 parsed.entry.subpath.as_ref().map(SourceSubpath::as_str),
441 Some("plugins/foo")
442 );
443 }
444
445 #[test]
446 fn conflicting_subpath_is_rejected() {
447 let err =
448 parse_dependency_specifier("owner/repo/plugins/foo", Some("plugins/bar")).unwrap_err();
449 assert!(matches!(err, MarsError::InvalidRequest { .. }));
450 }
451
452 #[test]
453 fn format_filter_all() {
454 assert_eq!(format_filter(&FilterConfig::default()), "all");
455 }
456
457 #[test]
458 fn format_filter_only_modes() {
459 assert_eq!(
460 format_filter(&FilterConfig {
461 only_skills: true,
462 ..FilterConfig::default()
463 }),
464 "only_skills=true"
465 );
466 assert_eq!(
467 format_filter(&FilterConfig {
468 only_agents: true,
469 ..FilterConfig::default()
470 }),
471 "only_agents=true"
472 );
473 }
474
475 #[test]
476 fn format_filter_lists() {
477 assert_eq!(
478 format_filter(&FilterConfig {
479 agents: Some(vec!["reviewer".into(), "planner".into()]),
480 ..FilterConfig::default()
481 }),
482 "agents=[reviewer,planner]"
483 );
484 assert_eq!(
485 format_filter(&FilterConfig {
486 exclude: Some(vec!["legacy".into()]),
487 ..FilterConfig::default()
488 }),
489 "exclude=[legacy]"
490 );
491 }
492
493 #[test]
494 fn detects_filter_change_for_message() {
495 let old_filter = FilterConfig {
496 agents: Some(vec!["reviewer".into()]),
497 ..FilterConfig::default()
498 };
499 let change = DependencyUpsertChange {
500 name: "ops".into(),
501 already_exists: true,
502 old_version: Some("v0.1.0".into()),
503 new_version: Some("v0.1.0".into()),
504 old_filter: Some(old_filter.clone()),
505 new_filter: FilterConfig {
506 only_skills: true,
507 ..FilterConfig::default()
508 },
509 };
510 assert_ne!(change.old_filter.as_ref(), Some(&change.new_filter));
511 assert_eq!(format_filter(&old_filter), "agents=[reviewer]");
512 assert_eq!(format_filter(&change.new_filter), "only_skills=true");
513 }
514}