Skip to main content

mars_agents/cli/
link.rs

1//! `mars link <target>` — add a managed target directory.
2//!
3//! `mars link <target>` adds the target to `settings.targets` and copies
4//! content from `.mars/` into that target.
5//! Use `mars unlink <target>` to remove a target.
6
7use crate::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCollector, DiagnosticLevel};
8use crate::error::MarsError;
9use crate::lock::{ItemId, ItemKind, LockFile};
10use crate::sync::apply::{ActionOutcome, ActionTaken};
11use crate::types::ItemName;
12use std::collections::HashSet;
13
14use super::output;
15
16/// Arguments for `mars link`.
17#[derive(Debug, clap::Args)]
18pub struct LinkArgs {
19    /// Target directory to materialize (e.g. `.claude`).
20    pub target: String,
21}
22
23/// Run `mars link`.
24pub fn run(args: &LinkArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
25    let target_name = super::target::normalize_target_name(&args.target)?;
26    link_target(ctx, &target_name, json)
27}
28
29fn link_target(ctx: &super::MarsContext, target_name: &str, json: bool) -> Result<i32, MarsError> {
30    let config_path = ctx.project_root.join("mars.toml");
31    if !config_path.exists() {
32        return Err(MarsError::Link {
33            target: target_name.to_string(),
34            message: format!(
35                "mars.toml not found at {} — run `mars init` first",
36                ctx.project_root.display()
37            ),
38        });
39    }
40
41    if !json
42        && !super::WELL_KNOWN.contains(&target_name)
43        && !super::TOOL_DIRS.contains(&target_name)
44    {
45        output::print_warn(&format!(
46            "`{target_name}` is not a recognized tool directory — managing anyway"
47        ));
48    }
49
50    let mars_dir = ctx.project_root.join(".mars");
51    std::fs::create_dir_all(&mars_dir)?;
52    let lock_path = mars_dir.join("sync.lock");
53    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
54
55    let mut config = crate::config::load(&ctx.project_root)?;
56    let mut targets = config
57        .settings
58        .targets
59        .clone()
60        .unwrap_or_else(|| config.settings.managed_targets());
61    if !targets.iter().any(|target| target == target_name) {
62        targets.push(target_name.to_string());
63    }
64
65    let settings_changed = config.settings.targets.as_ref() != Some(&targets);
66
67    let lock = crate::lock::load(&ctx.project_root)?;
68    let outcomes = lock_items_as_sync_outcomes(&lock);
69    let agent_surface_policy = crate::compiler::agent_surface_policy(
70        config.settings.agent_emission.as_ref(),
71        ctx.meridian_managed,
72    );
73    let suppressed_outcomes;
74    let sync_outcomes = if matches!(
75        agent_surface_policy,
76        crate::compiler::AgentSurfacePolicy::SuppressAll
77    ) {
78        suppressed_outcomes = crate::compiler::suppress_agent_outcomes(&outcomes);
79        &suppressed_outcomes
80    } else {
81        &outcomes
82    };
83    let previous_managed_paths = lock
84        .all_output_dest_paths()
85        .map(|dest_path| dest_path.to_string())
86        .collect::<HashSet<String>>();
87
88    let mut diag = DiagnosticCollector::new();
89    let target_outcomes = crate::target_sync::sync_managed_targets(
90        &ctx.project_root,
91        &mars_dir,
92        &[target_name.to_string()],
93        sync_outcomes,
94        &previous_managed_paths,
95        true,
96        &mut diag,
97    );
98    let mut diagnostics = diag.drain();
99    if let Some(diagnostic) = deprecated_agents_target_diagnostic(target_name) {
100        diagnostics.push(diagnostic);
101    }
102
103    let Some(outcome) = target_outcomes.first() else {
104        return Err(MarsError::Link {
105            target: target_name.to_string(),
106            message: "target sync produced no result".to_string(),
107        });
108    };
109
110    if !outcome.errors.is_empty() {
111        return Err(MarsError::Link {
112            target: target_name.to_string(),
113            message: outcome.errors.join("; "),
114        });
115    }
116
117    if settings_changed {
118        config.settings.targets = Some(targets);
119        crate::config::save(&ctx.project_root, &config)?;
120    }
121
122    if json {
123        output::print_json(&serde_json::json!({
124            "ok": true,
125            "target": target_name,
126            "settings_updated": settings_changed,
127            "synced": outcome.items_synced,
128            "removed": outcome.items_removed,
129            "diagnostics": diagnostics,
130        }));
131    } else {
132        output::print_success(&format!(
133            "managed target `{target_name}` (synced {}, removed {})",
134            outcome.items_synced, outcome.items_removed
135        ));
136        for diagnostic in diagnostics {
137            output::print_warn(&diagnostic.to_string());
138        }
139    }
140
141    Ok(0)
142}
143
144fn deprecated_agents_target_diagnostic(target_name: &str) -> Option<Diagnostic> {
145    (target_name == ".agents").then(|| Diagnostic {
146        level: DiagnosticLevel::Warning,
147        code: "deprecated-agents-target",
148        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(),
149        context: Some("link target".to_string()),
150        category: Some(DiagnosticCategory::Compatibility),
151    })
152}
153
154fn lock_items_as_sync_outcomes(lock: &LockFile) -> Vec<ActionOutcome> {
155    lock.flat_items()
156        .into_iter()
157        .map(|(dest_path, item)| ActionOutcome {
158            item_id: ItemId {
159                kind: item.kind,
160                name: item_name_from_dest_path(&dest_path, item.kind),
161            },
162            action: ActionTaken::Skipped,
163            dest_path,
164            source_name: item.source,
165            source_checksum: None,
166            installed_checksum: Some(item.installed_checksum),
167        })
168        .collect()
169}
170
171fn item_name_from_dest_path(dest_path: &crate::types::DestPath, kind: ItemKind) -> ItemName {
172    let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
173    let name = match kind {
174        ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
175        ItemKind::Skill | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
176            last.to_string()
177        }
178    };
179
180    ItemName::from(name)
181}