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