Skip to main content

mars_agents/cli/
add.rs

1//! `mars add <dependency>` — add or update a dependency, then sync.
2
3use 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/// Arguments for `mars add`.
14#[derive(Debug, clap::Args)]
15pub struct AddArgs {
16    /// Source specifiers (one or more): owner/repo, owner/repo@version, URL, or local path.
17    #[arg(required = true)]
18    pub sources: Vec<String>,
19
20    /// Root the fetched source at a package subdirectory.
21    #[arg(long)]
22    pub subpath: Option<String>,
23
24    /// Only install specific agents from this source.
25    #[arg(long, value_delimiter = ',')]
26    pub agents: Vec<String>,
27
28    /// Only install specific skills from this source.
29    #[arg(long, value_delimiter = ',')]
30    pub skills: Vec<String>,
31
32    /// Exclude specific items from this source.
33    #[arg(long, value_delimiter = ',')]
34    pub exclude: Vec<String>,
35
36    /// Install only skills from this source (no agents).
37    #[arg(long)]
38    pub only_skills: bool,
39
40    /// Install only agents (plus their transitive skill deps) from this source.
41    #[arg(long)]
42    pub only_agents: bool,
43}
44
45/// Parsed dependency specifier.
46#[derive(Debug)]
47struct ParsedDependency {
48    name: SourceName,
49    entry: DependencyEntry,
50}
51
52/// Run `mars add`.
53pub fn run(args: &AddArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
54    // Validate: filters require exactly one source
55    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    // Validate filter flag combinations early
73    let filter_config = build_filter_config(args);
74    crate::config::validate_filter(&filter_config, "cli")?;
75
76    // Build mutations for all sources
77    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    // For single source, use direct mutation path
94    // For multi-source, apply mutations sequentially then run one sync
95    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    // Multi-source: send one batch mutation through sync pipeline.
124    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
146/// Build FilterConfig from CLI args.
147fn 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
185/// Parse a dependency specifier string into a name + DependencyEntry.
186///
187/// Formats:
188/// - `owner/repo` → GitHub shorthand (no `.` in first segment, exactly one `/`)
189/// - `owner/repo@version` → GitHub shorthand with version
190/// - `github.com/owner/repo` → full git URL
191/// - `https://github.com/owner/repo.git` → full git URL
192/// - `./path` or `../path` or `/absolute` → local path
193fn 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}