|
1 | | -//! One-click install of the managed Cursor `preToolUse` workspace-context hook. |
2 | | -//! |
3 | | -//! Writes `~/.cursor/hooks/mcpmux-workspace-context.js` and merges one |
4 | | -//! `preToolUse` entry into plain `~/.cursor/hooks.json`. Refuses JSONC and |
5 | | -//! non-object shapes; preserves every unrelated hook. |
| 1 | +//! Tauri IPC wrappers for the managed Cursor `preToolUse` hook installer. |
6 | 2 |
|
7 | | -use std::path::{Path, PathBuf}; |
8 | | - |
9 | | -use serde::Serialize; |
10 | | -use serde_json::{json, Value}; |
11 | | - |
12 | | -const SCRIPT_NAME: &str = "mcpmux-workspace-context.js"; |
13 | | -const SCRIPT_SOURCE: &str = include_str!("../../scripts/mcpmux-workspace-context.js"); |
14 | | -const MATCHER: &str = "MCP:mcpmux_.*"; |
15 | | -const TIMEOUT_SECS: u64 = 5; |
16 | | - |
17 | | -/// Result of install / status / uninstall. |
18 | | -#[derive(Debug, Clone, Serialize)] |
19 | | -pub struct CursorHookResult { |
20 | | - pub action: String, |
21 | | - pub installed: bool, |
22 | | - pub hooks_path: String, |
23 | | - pub script_path: String, |
24 | | - pub backed_up: Option<String>, |
25 | | - pub error: Option<String>, |
26 | | - pub jsonc_refused: bool, |
27 | | - pub manual_entry: String, |
28 | | -} |
29 | | - |
30 | | -fn cursor_dir() -> Result<PathBuf, String> { |
31 | | - let home = dirs::home_dir().ok_or_else(|| "home directory not found".to_string())?; |
32 | | - Ok(home.join(".cursor")) |
33 | | -} |
34 | | - |
35 | | -fn hooks_json_path() -> Result<PathBuf, String> { |
36 | | - Ok(cursor_dir()?.join("hooks.json")) |
37 | | -} |
38 | | - |
39 | | -fn script_path() -> Result<PathBuf, String> { |
40 | | - Ok(cursor_dir()?.join("hooks").join(SCRIPT_NAME)) |
41 | | -} |
42 | | - |
43 | | -fn hook_command(script: &Path) -> String { |
44 | | - format!("node {}", script.display()) |
45 | | -} |
46 | | - |
47 | | -fn managed_entry(script: &Path) -> Value { |
48 | | - json!({ |
49 | | - "command": hook_command(script), |
50 | | - "matcher": MATCHER, |
51 | | - "timeout": TIMEOUT_SECS, |
52 | | - }) |
53 | | -} |
54 | | - |
55 | | -fn manual_entry_text(script: &Path) -> String { |
56 | | - serde_json::to_string_pretty(&json!({ |
57 | | - "version": 1, |
58 | | - "hooks": { |
59 | | - "preToolUse": [managed_entry(script)] |
60 | | - } |
61 | | - })) |
62 | | - .unwrap_or_else(|_| "{}".into()) |
63 | | -} |
64 | | - |
65 | | -fn empty_result(action: &str, error: Option<String>, jsonc_refused: bool) -> CursorHookResult { |
66 | | - let hooks = hooks_json_path() |
67 | | - .map(|p| p.to_string_lossy().into_owned()) |
68 | | - .unwrap_or_default(); |
69 | | - let script = script_path() |
70 | | - .map(|p| p.to_string_lossy().into_owned()) |
71 | | - .unwrap_or_default(); |
72 | | - let manual = script_path() |
73 | | - .map(|p| manual_entry_text(&p)) |
74 | | - .unwrap_or_else(|_| "{}".into()); |
75 | | - CursorHookResult { |
76 | | - action: action.into(), |
77 | | - installed: false, |
78 | | - hooks_path: hooks, |
79 | | - script_path: script, |
80 | | - backed_up: None, |
81 | | - error, |
82 | | - jsonc_refused, |
83 | | - manual_entry: manual, |
84 | | - } |
85 | | -} |
86 | | - |
87 | | -fn is_managed_entry(value: &Value, script: &Path) -> bool { |
88 | | - let Some(obj) = value.as_object() else { |
89 | | - return false; |
90 | | - }; |
91 | | - let command = obj.get("command").and_then(Value::as_str).unwrap_or(""); |
92 | | - command.contains(SCRIPT_NAME) || command == hook_command(script) |
93 | | -} |
94 | | - |
95 | | -fn parse_hooks_json(existing: &str) -> Result<Value, String> { |
96 | | - serde_json::from_str(existing).map_err(|_| { |
97 | | - "hooks.json is not plain JSON (JSONC/comments or invalid syntax). \ |
98 | | - Merge the manual entry yourself — McpMux will not rewrite this file." |
99 | | - .to_string() |
100 | | - }) |
101 | | -} |
102 | | - |
103 | | -fn merge_pre_tool_use(existing: Option<&str>, script: &Path) -> Result<String, String> { |
104 | | - let mut root = match existing { |
105 | | - Some(raw) if !raw.trim().is_empty() => parse_hooks_json(raw)?, |
106 | | - _ => json!({ "version": 1, "hooks": {} }), |
107 | | - }; |
108 | | - let obj = root |
109 | | - .as_object_mut() |
110 | | - .ok_or_else(|| "hooks.json root must be a JSON object".to_string())?; |
111 | | - obj.entry("version").or_insert(json!(1)); |
112 | | - let hooks = obj.entry("hooks").or_insert_with(|| json!({})); |
113 | | - let hooks_obj = hooks |
114 | | - .as_object_mut() |
115 | | - .ok_or_else(|| "hooks.json `hooks` key must be an object".to_string())?; |
116 | | - let list = hooks_obj.entry("preToolUse").or_insert_with(|| json!([])); |
117 | | - let arr = list |
118 | | - .as_array_mut() |
119 | | - .ok_or_else(|| "hooks.json `hooks.preToolUse` must be an array".to_string())?; |
120 | | - arr.retain(|entry| !is_managed_entry(entry, script)); |
121 | | - arr.push(managed_entry(script)); |
122 | | - serde_json::to_string_pretty(&root).map_err(|e| format!("failed to serialize hooks.json: {e}")) |
123 | | -} |
124 | | - |
125 | | -fn remove_managed_entry(existing: &str, script: &Path) -> Result<String, String> { |
126 | | - let mut root = parse_hooks_json(existing)?; |
127 | | - if let Some(arr) = root |
128 | | - .pointer_mut("/hooks/preToolUse") |
129 | | - .and_then(Value::as_array_mut) |
130 | | - { |
131 | | - arr.retain(|entry| !is_managed_entry(entry, script)); |
132 | | - } |
133 | | - serde_json::to_string_pretty(&root).map_err(|e| format!("failed to serialize hooks.json: {e}")) |
134 | | -} |
135 | | - |
136 | | -fn write_with_backup(path: &Path, contents: &str) -> Result<Option<String>, String> { |
137 | | - let mut backed_up = None; |
138 | | - if path.exists() { |
139 | | - let bak = PathBuf::from(format!("{}.mcpmux-bak", path.display())); |
140 | | - std::fs::copy(path, &bak).map_err(|e| format!("failed to back up hooks.json: {e}"))?; |
141 | | - backed_up = Some(bak.to_string_lossy().into_owned()); |
142 | | - } |
143 | | - if let Some(parent) = path.parent() { |
144 | | - std::fs::create_dir_all(parent).map_err(|e| format!("failed to create ~/.cursor: {e}"))?; |
145 | | - } |
146 | | - std::fs::write(path, contents).map_err(|e| format!("failed to write hooks.json: {e}"))?; |
147 | | - Ok(backed_up) |
148 | | -} |
149 | | - |
150 | | -fn write_script(script: &Path) -> Result<(), String> { |
151 | | - if let Some(parent) = script.parent() { |
152 | | - std::fs::create_dir_all(parent) |
153 | | - .map_err(|e| format!("failed to create ~/.cursor/hooks: {e}"))?; |
154 | | - } |
155 | | - std::fs::write(script, SCRIPT_SOURCE).map_err(|e| format!("failed to write hook script: {e}")) |
156 | | -} |
157 | | - |
158 | | -fn has_managed_entry(path: &Path, script: &Path) -> Result<bool, String> { |
159 | | - if !path.exists() { |
160 | | - return Ok(false); |
161 | | - } |
162 | | - let raw = |
163 | | - std::fs::read_to_string(path).map_err(|e| format!("failed to read hooks.json: {e}"))?; |
164 | | - let root = parse_hooks_json(&raw)?; |
165 | | - Ok(root |
166 | | - .pointer("/hooks/preToolUse") |
167 | | - .and_then(Value::as_array) |
168 | | - .is_some_and(|arr| arr.iter().any(|e| is_managed_entry(e, script)))) |
169 | | -} |
| 3 | +use mcpmux_gateway::cursor_hook::{self, CursorHookResult}; |
170 | 4 |
|
171 | 5 | /// Current install state of the managed Cursor hook. |
172 | 6 | #[tauri::command] |
173 | 7 | pub fn cursor_hook_status() -> CursorHookResult { |
174 | | - let Ok(hooks_path) = hooks_json_path() else { |
175 | | - return empty_result("status", Some("home directory not found".into()), false); |
176 | | - }; |
177 | | - let Ok(script) = script_path() else { |
178 | | - return empty_result("status", Some("home directory not found".into()), false); |
179 | | - }; |
180 | | - match has_managed_entry(&hooks_path, &script) { |
181 | | - Ok(has_entry) => { |
182 | | - let installed = has_entry && script.exists(); |
183 | | - CursorHookResult { |
184 | | - action: "status".into(), |
185 | | - installed, |
186 | | - hooks_path: hooks_path.to_string_lossy().into_owned(), |
187 | | - script_path: script.to_string_lossy().into_owned(), |
188 | | - backed_up: None, |
189 | | - error: None, |
190 | | - jsonc_refused: false, |
191 | | - manual_entry: manual_entry_text(&script), |
192 | | - } |
193 | | - } |
194 | | - Err(e) => { |
195 | | - let refused = e.contains("not plain JSON"); |
196 | | - empty_result("status", Some(e), refused) |
197 | | - } |
198 | | - } |
| 8 | + cursor_hook::status() |
199 | 9 | } |
200 | 10 |
|
201 | 11 | /// Install or update the managed `preToolUse` hook. |
202 | 12 | #[tauri::command] |
203 | 13 | pub fn install_cursor_hook() -> CursorHookResult { |
204 | | - let hooks_path = match hooks_json_path() { |
205 | | - Ok(p) => p, |
206 | | - Err(e) => return empty_result("error", Some(e), false), |
207 | | - }; |
208 | | - let script = match script_path() { |
209 | | - Ok(p) => p, |
210 | | - Err(e) => return empty_result("error", Some(e), false), |
211 | | - }; |
212 | | - if let Err(e) = write_script(&script) { |
213 | | - return empty_result("error", Some(e), false); |
214 | | - } |
215 | | - let existing = if hooks_path.exists() { |
216 | | - match std::fs::read_to_string(&hooks_path) { |
217 | | - Ok(s) => Some(s), |
218 | | - Err(e) => { |
219 | | - return empty_result( |
220 | | - "error", |
221 | | - Some(format!("failed to read hooks.json: {e}")), |
222 | | - false, |
223 | | - ) |
224 | | - } |
225 | | - } |
226 | | - } else { |
227 | | - None |
228 | | - }; |
229 | | - let existed = existing.is_some(); |
230 | | - let merged = match merge_pre_tool_use(existing.as_deref(), &script) { |
231 | | - Ok(m) => m, |
232 | | - Err(e) => { |
233 | | - let refused = e.contains("not plain JSON") || e.contains("must be"); |
234 | | - return empty_result("error", Some(e), refused); |
235 | | - } |
236 | | - }; |
237 | | - match write_with_backup(&hooks_path, &merged) { |
238 | | - Ok(backed_up) => CursorHookResult { |
239 | | - action: if existed { "updated" } else { "created" }.into(), |
240 | | - installed: true, |
241 | | - hooks_path: hooks_path.to_string_lossy().into_owned(), |
242 | | - script_path: script.to_string_lossy().into_owned(), |
243 | | - backed_up, |
244 | | - error: None, |
245 | | - jsonc_refused: false, |
246 | | - manual_entry: manual_entry_text(&script), |
247 | | - }, |
248 | | - Err(e) => empty_result("error", Some(e), false), |
249 | | - } |
| 14 | + cursor_hook::install() |
250 | 15 | } |
251 | 16 |
|
252 | 17 | /// Remove the managed hook entry and delete the managed script. |
253 | 18 | #[tauri::command] |
254 | 19 | pub fn uninstall_cursor_hook() -> CursorHookResult { |
255 | | - let hooks_path = match hooks_json_path() { |
256 | | - Ok(p) => p, |
257 | | - Err(e) => return empty_result("error", Some(e), false), |
258 | | - }; |
259 | | - let script = match script_path() { |
260 | | - Ok(p) => p, |
261 | | - Err(e) => return empty_result("error", Some(e), false), |
262 | | - }; |
263 | | - let mut backed_up = None; |
264 | | - if hooks_path.exists() { |
265 | | - let raw = match std::fs::read_to_string(&hooks_path) { |
266 | | - Ok(s) => s, |
267 | | - Err(e) => { |
268 | | - return empty_result( |
269 | | - "error", |
270 | | - Some(format!("failed to read hooks.json: {e}")), |
271 | | - false, |
272 | | - ) |
273 | | - } |
274 | | - }; |
275 | | - let rewritten = match remove_managed_entry(&raw, &script) { |
276 | | - Ok(s) => s, |
277 | | - Err(e) => { |
278 | | - let refused = e.contains("not plain JSON"); |
279 | | - return empty_result("error", Some(e), refused); |
280 | | - } |
281 | | - }; |
282 | | - match write_with_backup(&hooks_path, &rewritten) { |
283 | | - Ok(bak) => backed_up = bak, |
284 | | - Err(e) => return empty_result("error", Some(e), false), |
285 | | - } |
286 | | - } |
287 | | - if script.exists() { |
288 | | - if let Err(e) = std::fs::remove_file(&script) { |
289 | | - return empty_result( |
290 | | - "error", |
291 | | - Some(format!("failed to delete hook script: {e}")), |
292 | | - false, |
293 | | - ); |
294 | | - } |
295 | | - } |
296 | | - CursorHookResult { |
297 | | - action: "uninstalled".into(), |
298 | | - installed: false, |
299 | | - hooks_path: hooks_path.to_string_lossy().into_owned(), |
300 | | - script_path: script.to_string_lossy().into_owned(), |
301 | | - backed_up, |
302 | | - error: None, |
303 | | - jsonc_refused: false, |
304 | | - manual_entry: manual_entry_text(&script), |
305 | | - } |
| 20 | + cursor_hook::uninstall() |
306 | 21 | } |
0 commit comments