Skip to main content

mars_agents/cli/
link.rs

1//! `mars link <dir>` — manage target directories materialized from `.mars/`.
2//!
3//! `mars link <target>` adds the target to `settings.targets` and copies
4//! content from `.mars/` into that target.
5//! `mars link --unlink <target>` removes the target from `settings.targets`
6//! and removes the target directory.
7
8use crate::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCollector, DiagnosticLevel};
9use crate::error::MarsError;
10use crate::lock::{ItemId, ItemKind, LockFile};
11use crate::sync::apply::{ActionOutcome, ActionTaken};
12use crate::types::ItemName;
13use std::collections::HashSet;
14
15use super::output;
16
17/// Arguments for `mars link`.
18#[derive(Debug, clap::Args)]
19pub struct LinkArgs {
20    /// Target directory to materialize (e.g. `.claude`).
21    pub target: String,
22
23    /// Remove target management instead of adding it.
24    #[arg(long)]
25    pub unlink: bool,
26}
27
28/// Run `mars link`.
29pub fn run(args: &LinkArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
30    let target_name = normalize_target_name(&args.target)?;
31
32    if args.unlink {
33        return unlink_target(ctx, &target_name, json);
34    }
35
36    link_target(ctx, &target_name, json)
37}
38
39fn link_target(ctx: &super::MarsContext, target_name: &str, json: bool) -> Result<i32, MarsError> {
40    let config_path = ctx.project_root.join("mars.toml");
41    if !config_path.exists() {
42        return Err(MarsError::Link {
43            target: target_name.to_string(),
44            message: format!(
45                "mars.toml not found at {} — run `mars init` first",
46                ctx.project_root.display()
47            ),
48        });
49    }
50
51    if !json
52        && !super::WELL_KNOWN.contains(&target_name)
53        && !super::TOOL_DIRS.contains(&target_name)
54    {
55        output::print_warn(&format!(
56            "`{target_name}` is not a recognized tool directory — managing anyway"
57        ));
58    }
59
60    let mars_dir = ctx.project_root.join(".mars");
61    std::fs::create_dir_all(&mars_dir)?;
62    let lock_path = mars_dir.join("sync.lock");
63    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
64
65    let mut config = crate::config::load(&ctx.project_root)?;
66    let mut targets = config
67        .settings
68        .targets
69        .clone()
70        .unwrap_or_else(|| config.settings.managed_targets());
71    if !targets.iter().any(|target| target == target_name) {
72        targets.push(target_name.to_string());
73    }
74
75    let settings_changed = config.settings.targets.as_ref() != Some(&targets);
76
77    let lock = crate::lock::load(&ctx.project_root)?;
78    let outcomes = lock_items_as_sync_outcomes(&lock);
79    let agent_surface_policy = crate::compiler::agent_surface_policy(
80        config.settings.agent_emission.as_ref(),
81        ctx.meridian_managed,
82    );
83    let suppressed_outcomes;
84    let sync_outcomes = if matches!(
85        agent_surface_policy,
86        crate::compiler::AgentSurfacePolicy::SuppressAll
87    ) {
88        suppressed_outcomes = crate::compiler::suppress_agent_outcomes(&outcomes);
89        &suppressed_outcomes
90    } else {
91        &outcomes
92    };
93    let previous_managed_paths = lock
94        .all_output_dest_paths()
95        .map(|dest_path| dest_path.to_string())
96        .collect::<HashSet<String>>();
97
98    let mut diag = DiagnosticCollector::new();
99    let target_outcomes = crate::target_sync::sync_managed_targets(
100        &ctx.project_root,
101        &mars_dir,
102        &[target_name.to_string()],
103        sync_outcomes,
104        &previous_managed_paths,
105        true,
106        &mut diag,
107    );
108    let mut diagnostics = diag.drain();
109    if let Some(diagnostic) = deprecated_agents_target_diagnostic(target_name) {
110        diagnostics.push(diagnostic);
111    }
112
113    let Some(outcome) = target_outcomes.first() else {
114        return Err(MarsError::Link {
115            target: target_name.to_string(),
116            message: "target sync produced no result".to_string(),
117        });
118    };
119
120    if !outcome.errors.is_empty() {
121        return Err(MarsError::Link {
122            target: target_name.to_string(),
123            message: outcome.errors.join("; "),
124        });
125    }
126
127    if settings_changed {
128        config.settings.targets = Some(targets);
129        crate::config::save(&ctx.project_root, &config)?;
130    }
131
132    if json {
133        output::print_json(&serde_json::json!({
134            "ok": true,
135            "target": target_name,
136            "settings_updated": settings_changed,
137            "synced": outcome.items_synced,
138            "removed": outcome.items_removed,
139            "diagnostics": diagnostics,
140        }));
141    } else {
142        output::print_success(&format!(
143            "managed target `{target_name}` (synced {}, removed {})",
144            outcome.items_synced, outcome.items_removed
145        ));
146        for diagnostic in diagnostics {
147            output::print_warn(&diagnostic.to_string());
148        }
149    }
150
151    Ok(0)
152}
153
154fn deprecated_agents_target_diagnostic(target_name: &str) -> Option<Diagnostic> {
155    (target_name == ".agents").then(|| Diagnostic {
156        level: DiagnosticLevel::Warning,
157        code: "deprecated-agents-target",
158        message: "`.agents` is a deprecated link target. Run `mars unlink .agents` to remove it. Skills are now emitted to native harness dirs automatically.".to_string(),
159        context: Some("link target".to_string()),
160        category: Some(DiagnosticCategory::Compatibility),
161    })
162}
163
164fn unlink_target(
165    ctx: &super::MarsContext,
166    target_name: &str,
167    json: bool,
168) -> Result<i32, MarsError> {
169    let mars_dir = ctx.project_root.join(".mars");
170    std::fs::create_dir_all(&mars_dir)?;
171    let lock_path = mars_dir.join("sync.lock");
172    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
173
174    let mut config = crate::config::load(&ctx.project_root)?;
175    let mut settings_updated = false;
176    let mut target_was_managed = false;
177
178    if config.settings.managed_root.as_deref() == Some(target_name) {
179        config.settings.managed_root = None;
180        settings_updated = true;
181        target_was_managed = true;
182    }
183
184    if let Some(targets) = config.settings.targets.as_mut() {
185        let old_len = targets.len();
186        targets.retain(|target| target != target_name);
187        if targets.len() != old_len {
188            settings_updated = true;
189            target_was_managed = true;
190        }
191        if targets.is_empty() {
192            config.settings.targets = None;
193        }
194    }
195
196    if settings_updated {
197        crate::config::save(&ctx.project_root, &config)?;
198    }
199
200    let target_dir = ctx.project_root.join(target_name);
201    let removed_dir = if target_was_managed && target_dir.exists() {
202        std::fs::remove_dir_all(&target_dir)?;
203        true
204    } else {
205        false
206    };
207
208    if json {
209        output::print_json(&serde_json::json!({
210            "ok": true,
211            "target": target_name,
212            "settings_updated": settings_updated,
213            "removed_dir": removed_dir,
214        }));
215    } else if removed_dir {
216        output::print_success(&format!("removed managed target `{target_name}`"));
217    } else {
218        output::print_info(&format!("removed `{target_name}` from settings.targets"));
219    }
220
221    Ok(0)
222}
223
224fn normalize_target_name(target: &str) -> Result<String, MarsError> {
225    let normalized = target.trim_end_matches('/').trim_end_matches('\\');
226    if normalized.contains('/') || normalized.contains('\\') {
227        return Err(MarsError::Link {
228            target: target.to_string(),
229            message: "link target must be a directory name, not a path".to_string(),
230        });
231    }
232    if normalized.is_empty() || normalized == "." || normalized == ".." {
233        return Err(MarsError::Link {
234            target: target.to_string(),
235            message: "invalid link target name".to_string(),
236        });
237    }
238    Ok(normalized.to_string())
239}
240
241fn lock_items_as_sync_outcomes(lock: &LockFile) -> Vec<ActionOutcome> {
242    lock.flat_items()
243        .into_iter()
244        .map(|(dest_path, item)| ActionOutcome {
245            item_id: ItemId {
246                kind: item.kind,
247                name: item_name_from_dest_path(&dest_path, item.kind),
248            },
249            action: ActionTaken::Skipped,
250            dest_path,
251            source_name: item.source,
252            source_checksum: None,
253            installed_checksum: Some(item.installed_checksum),
254        })
255        .collect()
256}
257
258fn item_name_from_dest_path(dest_path: &crate::types::DestPath, kind: ItemKind) -> ItemName {
259    let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
260    let name = match kind {
261        ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
262        ItemKind::Skill | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
263            last.to_string()
264        }
265    };
266
267    ItemName::from(name)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::normalize_target_name;
273
274    #[test]
275    fn normalize_strips_trailing_slash() {
276        assert_eq!(normalize_target_name(".claude/").unwrap(), ".claude");
277    }
278
279    #[test]
280    fn normalize_rejects_path() {
281        assert!(normalize_target_name("foo/bar").is_err());
282    }
283
284    #[test]
285    fn normalize_rejects_empty() {
286        assert!(normalize_target_name("").is_err());
287    }
288
289    #[test]
290    fn normalize_rejects_dots() {
291        assert!(normalize_target_name(".").is_err());
292        assert!(normalize_target_name("..").is_err());
293    }
294}