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::surface_ownership::CollisionAdoptHint;
11use crate::sync::apply::{ActionOutcome, ActionTaken};
12use crate::types::ItemName;
13use crate::types::managed_cmd;
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    /// Adopt untracked collisions in the linked target (overwrite + record in lock).
23    #[arg(long)]
24    pub force: bool,
25}
26
27/// Run `mars link`.
28pub fn run(args: &LinkArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
29    let parsed_target = super::target::normalize_target_name(&args.target)?;
30    let target_name = crate::config::migrations::link::normalize_link(&parsed_target).target;
31    link_target(ctx, &target_name, args.force, json)
32}
33
34fn link_target(
35    ctx: &super::MarsContext,
36    target_name: &str,
37    force: bool,
38    json: bool,
39) -> 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 `{cmd}` first",
46                ctx.project_root.display(),
47                cmd = managed_cmd("mars init"),
48            ),
49        });
50    }
51
52    if !json
53        && !super::WELL_KNOWN.contains(&target_name)
54        && !super::TOOL_DIRS.contains(&target_name)
55    {
56        output::print_warn(&format!(
57            "`{target_name}` is not a recognized tool directory — managing anyway"
58        ));
59    }
60
61    let mars_dir = ctx.project_root.join(".mars");
62    std::fs::create_dir_all(&mars_dir)?;
63    let lock_path = mars_dir.join("sync.lock");
64    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
65
66    let config = crate::config::load(&ctx.project_root)?;
67    let local = crate::config::load_local(&ctx.project_root)?;
68    let (effective, _) =
69        crate::config::merge_with_root(config.clone(), local.clone(), &ctx.project_root)?;
70    let mut runtime_targets = effective.settings.managed_targets();
71    let mut persisted_targets = config.settings.managed_targets();
72    ensure_target_list_contains(&mut runtime_targets, target_name);
73    ensure_target_list_contains(&mut persisted_targets, target_name);
74
75    let settings_changed = config.settings.targets.as_ref() != Some(&persisted_targets);
76
77    let lock = crate::lock::load(&ctx.project_root)?;
78    let outcomes = lock_items_as_sync_outcomes(&lock);
79    let mut diag = DiagnosticCollector::new();
80    let agent_copy_spec = crate::compiler::agent_copy::build_agent_copy_spec(
81        effective.settings.agent_copy.as_ref(),
82        &runtime_targets,
83        &mut diag,
84    );
85    let agent_surface_policy = crate::compiler::agent_surface_policy(
86        effective.settings.agent_emission.as_ref(),
87        agent_copy_spec.as_ref(),
88        ctx.meridian_managed,
89    );
90
91    let cache = crate::source::GlobalCache::new()?;
92    let source_provider = crate::sync::provider::RealSourceProvider {
93        cache: &cache,
94        project_root: &ctx.project_root,
95    };
96    let resolve_options = crate::resolve::ResolveOptions::sync();
97    let graph = crate::resolve::resolve(
98        &effective,
99        &source_provider,
100        Some(&lock),
101        &resolve_options,
102        &mut diag,
103    )?;
104
105    let filtered_outcomes;
106    let orphan_preserve_paths;
107    let (sync_outcomes, orphan_preserve) = match &agent_surface_policy {
108        crate::compiler::AgentSurfacePolicy::SuppressAll => {
109            filtered_outcomes = crate::compiler::suppress_agent_outcomes(&outcomes);
110            (&filtered_outcomes, None)
111        }
112        crate::compiler::AgentSurfacePolicy::EmitSelective(spec) => {
113            orphan_preserve_paths =
114                crate::compiler::selective_native_orphan_preserve_paths(&lock, spec);
115            filtered_outcomes = crate::compiler::omit_agent_outcomes(&outcomes);
116            (&filtered_outcomes, Some(&orphan_preserve_paths))
117        }
118        crate::compiler::AgentSurfacePolicy::EmitAll => (&outcomes, None),
119    };
120    let target_sync_ctx = crate::target_sync::TargetSyncContext {
121        old_lock: &lock,
122        force,
123        collision_hint: CollisionAdoptHint::LinkForce,
124        orphan_preserve_paths: orphan_preserve,
125    };
126    let target_outcomes = crate::target_sync::sync_managed_targets(
127        &ctx.project_root,
128        &mars_dir,
129        &[target_name.to_string()],
130        sync_outcomes,
131        &target_sync_ctx,
132        &mut diag,
133    );
134    let mut diagnostics = diag.drain();
135    if let Some(diagnostic) = deprecated_agents_target_diagnostic(target_name) {
136        diagnostics.push(diagnostic);
137    }
138
139    if !force
140        && diagnostics
141            .iter()
142            .any(|d| d.code == "target-unmanaged-collision")
143    {
144        return Err(MarsError::Link {
145            target: target_name.to_string(),
146            message: format!(
147                "unmanaged collision in `{target_name}` — hand-written files would be skipped; \
148                 run `{}` to adopt",
149                managed_cmd(&format!("mars link {target_name} --force")),
150            ),
151        });
152    }
153
154    let Some(outcome) = target_outcomes.first() else {
155        return Err(MarsError::Link {
156            target: target_name.to_string(),
157            message: "target sync produced no result".to_string(),
158        });
159    };
160
161    if !outcome.errors.is_empty() {
162        return Err(MarsError::Link {
163            target: target_name.to_string(),
164            message: outcome.errors.join("; "),
165        });
166    }
167
168    let (compiled_native_outputs, removed_native_outputs) =
169        crate::compiler::materialize_native_agents_after_link(
170            &crate::compiler::NativeAgentLinkMaterializeCtx {
171                mars_ctx: ctx,
172                managed_targets: &runtime_targets,
173                config: &config,
174                local: &local,
175                effective: &effective,
176                graph: &graph,
177                old_lock: &lock,
178                target_outcomes: &target_outcomes,
179                force,
180            },
181            &mut diag,
182        );
183    diagnostics.extend(diag.drain());
184
185    if !force
186        && diagnostics
187            .iter()
188            .any(|d| d.code == "target-unmanaged-collision")
189    {
190        return Err(MarsError::Link {
191            target: target_name.to_string(),
192            message: format!(
193                "unmanaged collision in `{target_name}` — hand-written files would be skipped; \
194                 run `{}` to adopt",
195                managed_cmd(&format!("mars link {target_name} --force")),
196            ),
197        });
198    }
199
200    let mut new_lock = lock;
201    crate::lock::apply_target_sync_outputs(&mut new_lock, &target_outcomes);
202    crate::lock::apply_removed_native_outputs(&mut new_lock, &removed_native_outputs);
203    crate::lock::apply_compiled_native_outputs(&mut new_lock, &compiled_native_outputs);
204    crate::lock::write(&ctx.project_root, &new_lock)?;
205
206    if settings_changed {
207        let mut config = config;
208        config.settings.targets = Some(persisted_targets);
209        crate::config::save(&ctx.project_root, &config)?;
210    }
211
212    if json {
213        output::print_json(&serde_json::json!({
214            "ok": true,
215            "target": target_name,
216            "settings_updated": settings_changed,
217            "synced": outcome.items_synced,
218            "removed": outcome.items_removed,
219            "diagnostics": diagnostics,
220        }));
221    } else {
222        output::print_success(&format!(
223            "managed target `{target_name}` (synced {}, removed {})",
224            outcome.items_synced, outcome.items_removed
225        ));
226        for diagnostic in diagnostics {
227            output::print_warn(&diagnostic.to_string());
228        }
229    }
230
231    Ok(0)
232}
233
234fn ensure_target_list_contains(targets: &mut Vec<String>, target_name: &str) {
235    if !targets.iter().any(|target| target == target_name) {
236        targets.push(target_name.to_string());
237    }
238}
239
240fn deprecated_agents_target_diagnostic(target_name: &str) -> Option<Diagnostic> {
241    (target_name == ".agents").then(|| Diagnostic {
242        level: DiagnosticLevel::Warning,
243        code: "deprecated-agents-target",
244        message: format!(
245            "`.agents` is a deprecated link target. Run `{}` to remove it. Skills are now emitted to native harness dirs automatically.",
246            managed_cmd("mars unlink .agents"),
247        ),
248        context: Some("link target".to_string()),
249        category: Some(DiagnosticCategory::Compatibility),
250    })
251}
252
253fn lock_items_as_sync_outcomes(lock: &LockFile) -> Vec<ActionOutcome> {
254    lock.canonical_flat_items()
255        .into_iter()
256        .map(|(dest_path, item)| ActionOutcome {
257            item_id: ItemId {
258                kind: item.kind,
259                name: item_name_from_dest_path(&dest_path, item.kind),
260            },
261            action: ActionTaken::Skipped,
262            dest_path,
263            source_name: item.source,
264            source_checksum: None,
265            installed_checksum: Some(item.installed_checksum),
266        })
267        .collect()
268}
269
270fn item_name_from_dest_path(dest_path: &crate::types::DestPath, kind: ItemKind) -> ItemName {
271    let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
272    let name = match kind {
273        ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
274        ItemKind::Skill | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
275            last.to_string()
276        }
277    };
278
279    ItemName::from(name)
280}