1use std::path::{Path, PathBuf};
9
10use crate::error::MarsError;
11use crate::lock::ItemKind;
12use crate::types::DestPath;
13
14use super::{ConfigEntry, HookEntry, HookFragmentMode, McpServerEntry, TargetAdapter};
15
16#[derive(Debug)]
17pub struct CursorAdapter;
18
19impl TargetAdapter for CursorAdapter {
20 fn name(&self) -> &str {
21 ".cursor"
22 }
23
24 fn known_hook_events(&self) -> Option<&'static [&'static str]> {
25 Some(&[
28 "beforeShellExecution",
29 "beforeMCPExecution",
30 "afterShellExecution",
31 "afterMCPExecution",
32 "beforeReadFile",
33 "afterFileEdit",
34 "beforeTabFileRead",
35 "afterTabFileEdit",
36 "stop",
37 "beforeSubmitPrompt",
38 "afterAgentResponse",
39 "afterAgentThought",
40 "sessionStart",
41 "sessionEnd",
42 "preCompact",
43 "subagentStart",
44 "subagentStop",
45 "preToolUse",
46 "postToolUse",
47 "postToolUseFailure",
48 "workspaceOpen",
49 ])
50 }
51
52 fn hook_fragment_mode(&self) -> Option<HookFragmentMode> {
53 Some(HookFragmentMode::MergeJson)
54 }
55
56 fn skill_variant_key(&self) -> Option<&str> {
57 Some("cursor")
58 }
59
60 fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
61 match kind {
62 ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
63 _ => None,
64 }
65 }
66
67 fn write_config_entries(
68 &self,
69 write: crate::surface_ownership::retention::ConfigWrite<'_>,
70 project_root: &Path,
71 ) -> Result<Vec<PathBuf>, MarsError> {
72 let (target_dir, entries) = write.into_parts(project_root);
73 let mcp_servers: Vec<&McpServerEntry> = entries
74 .iter()
75 .filter_map(|e| {
76 if let ConfigEntry::McpServer(s) = e {
77 Some(s)
78 } else {
79 None
80 }
81 })
82 .collect();
83 let hooks: Vec<&HookEntry> = entries
84 .iter()
85 .filter_map(|entry| match entry {
86 ConfigEntry::Hook(hook) => Some(hook),
87 ConfigEntry::McpServer(_) => None,
88 })
89 .collect();
90
91 let mut written = Vec::new();
92 if !mcp_servers.is_empty() {
93 written.push((write_cursor_mcp_json)(&target_dir, &mcp_servers)?);
94 }
95 if !hooks.is_empty() {
96 written.push((write_cursor_hooks_json)(&target_dir, &hooks)?);
97 }
98 Ok(written)
99 }
100
101 fn mcp_config_file_names(&self) -> &'static [&'static str] {
102 &["mcp.json"]
103 }
104 fn hook_config_file_names(&self) -> &'static [&'static str] {
105 &["hooks.json"]
106 }
107
108 fn remove_owned_hook_entries(
109 &self,
110 operation: crate::surface_ownership::retention::RemovalOperation<'_>,
111 project_root: &Path,
112 diag: &mut crate::diagnostic::DiagnosticCollector,
113 ) -> crate::surface_ownership::retention::RemovalReport {
114 let (target_dir, removal) = operation.into_parts(project_root);
115 match remove_owned_cursor_hooks(&removal.prior_records, &target_dir, diag) {
116 Ok(()) => crate::surface_ownership::retention::RemovalReport::confirmed(),
117 Err(error) => crate::surface_ownership::retention::RemovalReport::failed(
118 error,
119 removal.prior_records.clone(),
120 ),
121 }
122 }
123
124 fn remove_config_entries(
125 &self,
126 operation: crate::surface_ownership::retention::RemovalOperation<'_>,
127 project_root: &Path,
128 ) -> crate::surface_ownership::retention::RemovalReport {
129 let (target_dir, removal) = operation.into_parts(project_root);
130 match remove_cursor_mcp_entries(&removal.keys_to_remove, &target_dir) {
131 Ok(()) => crate::surface_ownership::retention::RemovalReport::confirmed(),
132 Err(error) => crate::surface_ownership::retention::RemovalReport::failed(
133 error,
134 removal.prior_records.clone(),
135 ),
136 }
137 }
138}
139
140fn write_cursor_hooks_json(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
142 let path = target_dir.join("hooks.json");
143 let mut root: serde_json::Value = if path.is_file() {
144 super::parse_json_file(&path)?
145 } else {
146 serde_json::json!({})
147 };
148 let root_object = root.as_object_mut().ok_or_else(|| {
149 MarsError::Config(crate::error::ConfigError::Invalid {
150 message: format!("{} is not a JSON object", path.display()),
151 })
152 })?;
153 root_object.insert("version".into(), serde_json::json!(1));
154 let hooks_map = root_object
155 .entry("hooks")
156 .or_insert_with(|| serde_json::json!({}))
157 .as_object_mut()
158 .ok_or_else(|| {
159 MarsError::Config(crate::error::ConfigError::Invalid {
160 message: format!("{}: hooks is not an object", path.display()),
161 })
162 })?;
163 for hook in hooks {
164 super::append_json_event_entries(hooks_map, &hook.native_event, &hook.entries, &path)?;
165 }
166 crate::fs::atomic_write(
167 &path,
168 serde_json::to_string_pretty(&root)
169 .map_err(|error| {
170 MarsError::Config(crate::error::ConfigError::Invalid {
171 message: format!("failed to serialize {}: {error}", path.display()),
172 })
173 })?
174 .as_bytes(),
175 )?;
176 Ok(path)
177}
178
179fn remove_owned_cursor_hooks(
180 records: &std::collections::BTreeMap<String, crate::lock::ConfigEntryRecord>,
181 target_dir: &Path,
182 diag: &mut crate::diagnostic::DiagnosticCollector,
183) -> Result<(), MarsError> {
184 let path = target_dir.join("hooks.json");
185 if !path.is_file() {
186 return Ok(());
187 }
188 let mut root = super::parse_json_file(&path)?;
189 let mut changed = false;
190 if let Some(hooks_map) = root
191 .get_mut("hooks")
192 .and_then(serde_json::Value::as_object_mut)
193 {
194 for (key, record) in records {
195 let Some((event, name)) = key
196 .strip_prefix("hook:")
197 .and_then(|rest| rest.split_once(':'))
198 else {
199 continue;
200 };
201 let Some(expected) = record
202 .emitted_json
203 .as_deref()
204 .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(json).ok())
205 else {
206 continue;
207 };
208 let update = super::remove_json_event_entries(hooks_map, event, &expected);
209 changed |= update.changed;
210 if update.missing > 0 {
211 diag.warn(
212 "config-divergence",
213 format!(
214 "config-divergence: managed hook `{name}` diverged in target `.cursor` at `{}`; preserving edited config and appending the package entry",
215 path.display()
216 ),
217 );
218 }
219 }
220 }
221 if changed
222 && root
223 .get("hooks")
224 .and_then(serde_json::Value::as_object)
225 .is_some_and(serde_json::Map::is_empty)
226 {
227 root.as_object_mut().unwrap().remove("hooks");
228 }
229 if !changed {
230 return Ok(());
231 }
232 crate::fs::atomic_write(
233 &path,
234 serde_json::to_string_pretty(&root)
235 .map_err(|error| {
236 MarsError::Config(crate::error::ConfigError::Invalid {
237 message: format!("failed to serialize {}: {error}", path.display()),
238 })
239 })?
240 .as_bytes(),
241 )
242}
243
244fn write_cursor_mcp_json(
260 target_dir: &Path,
261 servers: &[&McpServerEntry],
262) -> Result<PathBuf, MarsError> {
263 let path = target_dir.join("mcp.json");
264
265 let mut root: serde_json::Value = if path.is_file() {
266 super::parse_json_file(&path)?
267 } else {
268 serde_json::json!({})
269 };
270
271 let mcp_obj = root
272 .as_object_mut()
273 .ok_or_else(|| {
274 MarsError::Config(crate::error::ConfigError::Invalid {
275 message: format!("{} is not a JSON object", path.display()),
276 })
277 })?
278 .entry("mcpServers")
279 .or_insert_with(|| serde_json::json!({}));
280
281 let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
282 MarsError::Config(crate::error::ConfigError::Invalid {
283 message: format!("{}: mcpServers is not an object", path.display()),
284 })
285 })?;
286
287 for server in servers {
288 let mut entry = serde_json::json!({
289 "command": server.command,
290 "args": server.args,
291 });
292
293 if !server.env.is_empty() {
295 let env_obj: serde_json::Map<String, serde_json::Value> = server
296 .env
297 .iter()
298 .map(|(k, v)| {
299 (
300 k.clone(),
301 serde_json::Value::String(format!("${{env:{v}}}")),
302 )
303 })
304 .collect();
305 entry["env"] = serde_json::Value::Object(env_obj);
306 }
307
308 mcp_map.insert(server.name.clone(), entry);
309 }
310
311 let content = serde_json::to_string_pretty(&root).map_err(|e| {
312 MarsError::Config(crate::error::ConfigError::Invalid {
313 message: format!("failed to serialize {}: {e}", path.display()),
314 })
315 })?;
316 crate::fs::atomic_write(&path, content.as_bytes())?;
317
318 Ok(path)
319}
320
321fn remove_cursor_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
322 let path = target_dir.join("mcp.json");
323 if !path.is_file() {
324 return Ok(());
325 }
326
327 let mut root = super::parse_json_file(&path)?;
328
329 if let Some(mcp_map) = root
330 .as_object_mut()
331 .and_then(|o| o.get_mut("mcpServers"))
332 .and_then(|v| v.as_object_mut())
333 {
334 for key in entry_keys {
335 if let Some(name) = key.strip_prefix("mcp:") {
336 mcp_map.remove(name);
337 }
338 }
339 }
340
341 let content = serde_json::to_string_pretty(&root).map_err(|e| {
342 MarsError::Config(crate::error::ConfigError::Invalid {
343 message: format!("failed to serialize {}: {e}", path.display()),
344 })
345 })?;
346 crate::fs::atomic_write(&path, content.as_bytes())?;
347 Ok(())
348}
349
350#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::surface_ownership::retention::WritePermit;
358
359 fn write_permit(entries: &[ConfigEntry]) -> WritePermit<'static> {
360 WritePermit::for_test("", entries[0].surface())
361 }
362 use crate::target::McpServerEntry;
363 use indexmap::IndexMap;
364 use tempfile::TempDir;
365
366 fn make_mcp_entry(name: &str, env_var: Option<(&str, &str)>) -> ConfigEntry {
367 let mut env = IndexMap::new();
368 if let Some((k, v)) = env_var {
369 env.insert(k.to_string(), v.to_string());
370 }
371 ConfigEntry::McpServer(McpServerEntry {
372 name: name.to_string(),
373 command: "npx".to_string(),
374 args: vec![],
375 env,
376 })
377 }
378
379 #[test]
380 fn write_mcp_creates_mcp_json() {
381 let tmp = TempDir::new().unwrap();
382 let adapter = CursorAdapter;
383 let entries = vec![make_mcp_entry("context7", None)];
384 let written = adapter
385 .write_config_entries(
386 write_permit(&entries)
387 .bind_config_entries(entries.clone())
388 .unwrap(),
389 tmp.path(),
390 )
391 .unwrap();
392 assert_eq!(written.len(), 1);
393 assert!(tmp.path().join("mcp.json").exists());
394
395 let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
396 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
397 assert!(json["mcpServers"]["context7"].is_object());
398 }
399
400 #[test]
401 fn write_mcp_env_uses_cursor_interpolation() {
402 let tmp = TempDir::new().unwrap();
403 let adapter = CursorAdapter;
404 let entries = vec![make_mcp_entry("server", Some(("API_KEY", "MY_SECRET")))];
405 adapter
406 .write_config_entries(
407 write_permit(&entries)
408 .bind_config_entries(entries.clone())
409 .unwrap(),
410 tmp.path(),
411 )
412 .unwrap();
413
414 let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
415 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
416 assert_eq!(
418 json["mcpServers"]["server"]["env"]["API_KEY"],
419 "${env:MY_SECRET}"
420 );
421 }
422
423 #[test]
424 fn remove_mcp_entries_preserves_others() {
425 let tmp = TempDir::new().unwrap();
426 let adapter = CursorAdapter;
427 let entries = vec![
428 make_mcp_entry("to-remove", None),
429 make_mcp_entry("to-keep", None),
430 ];
431 adapter
432 .write_config_entries(
433 write_permit(&entries)
434 .bind_config_entries(entries.clone())
435 .unwrap(),
436 tmp.path(),
437 )
438 .unwrap();
439
440 remove_cursor_mcp_entries(&["mcp:to-remove".to_string()], tmp.path()).unwrap();
441
442 let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
443 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
444 assert!(json["mcpServers"]["to-remove"].is_null());
445 assert!(json["mcpServers"]["to-keep"].is_object());
446 }
447
448 #[test]
449 fn divergent_structural_removal_does_not_rewrite_hooks() {
450 let tmp = TempDir::new().unwrap();
451 let path = tmp.path().join("hooks.json");
452 let original =
453 br#"{"version":1,"hooks":{"sessionStart":[{"command":"edited"}]},"keep":true}"#;
454 std::fs::write(&path, original).unwrap();
455 let before_modified = std::fs::metadata(&path).unwrap().modified().unwrap();
456 std::thread::sleep(std::time::Duration::from_millis(20));
457 let records = std::collections::BTreeMap::from([(
458 "hook:sessionStart:audit".to_string(),
459 crate::lock::ConfigEntryRecord {
460 emitted_json: Some(serde_json::json!([{"command":"original"}]).to_string()),
461 },
462 )]);
463
464 remove_owned_cursor_hooks(
465 &records,
466 tmp.path(),
467 &mut crate::diagnostic::DiagnosticCollector::new(),
468 )
469 .unwrap();
470
471 assert_eq!(std::fs::read(&path).unwrap(), original);
472 assert_eq!(
473 std::fs::metadata(&path).unwrap().modified().unwrap(),
474 before_modified
475 );
476 }
477
478 #[test]
479 fn removal_prunes_empty_hooks_object() {
480 let tmp = TempDir::new().unwrap();
481 let path = tmp.path().join("hooks.json");
482 let owned = serde_json::json!({"command":"owned"});
483 std::fs::write(
484 &path,
485 serde_json::json!({
486 "version": 1,
487 "hooks": {"sessionStart": [owned.clone()]}
488 })
489 .to_string(),
490 )
491 .unwrap();
492 let records = std::collections::BTreeMap::from([(
493 "hook:sessionStart:audit".to_string(),
494 crate::lock::ConfigEntryRecord {
495 emitted_json: Some(serde_json::json!([owned]).to_string()),
496 },
497 )]);
498
499 remove_owned_cursor_hooks(
500 &records,
501 tmp.path(),
502 &mut crate::diagnostic::DiagnosticCollector::new(),
503 )
504 .unwrap();
505
506 let after: serde_json::Value =
507 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
508 assert_eq!(after, serde_json::json!({"version": 1}));
509 }
510}