Skip to content

Commit 86c339e

Browse files
committed
feat(desktop): install a managed Cursor preToolUse workspace hook
Write the Node script under ~/.cursor/hooks, merge one matcher into plain hooks.json with backup, and expose install/status/uninstall on the global Cursor bridge result screen. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 5ed4db6 commit 86c339e

8 files changed

Lines changed: 593 additions & 1 deletion

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
// Managed McpMux preToolUse hook. Injects _mcpmux_context when Cursor
5+
// reports exactly one workspace root. Fail-open on any parse error.
6+
7+
const CONTEXT_KEY = '_mcpmux_context';
8+
9+
function allow() {
10+
process.stdout.write(JSON.stringify({ permission: 'allow' }));
11+
}
12+
13+
let raw = '';
14+
process.stdin.setEncoding('utf8');
15+
process.stdin.on('data', (chunk) => {
16+
raw += chunk;
17+
});
18+
process.stdin.on('end', () => {
19+
try {
20+
const payload = JSON.parse(raw || '{}');
21+
const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
22+
if (roots.length !== 1) {
23+
allow();
24+
return;
25+
}
26+
const root = String(roots[0] || '').trim();
27+
if (!root) {
28+
allow();
29+
return;
30+
}
31+
const input =
32+
payload.tool_input &&
33+
typeof payload.tool_input === 'object' &&
34+
!Array.isArray(payload.tool_input)
35+
? { ...payload.tool_input }
36+
: {};
37+
const context = { workspace_root: root };
38+
if (typeof payload.tool_use_id === 'string' && payload.tool_use_id) {
39+
context.tool_use_id = payload.tool_use_id;
40+
}
41+
input[CONTEXT_KEY] = context;
42+
process.stdout.write(JSON.stringify({ permission: 'allow', updated_input: input }));
43+
} catch {
44+
allow();
45+
}
46+
});
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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.
6+
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+
}
170+
171+
/// Current install state of the managed Cursor hook.
172+
#[tauri::command]
173+
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+
}
199+
}
200+
201+
/// Install or update the managed `preToolUse` hook.
202+
#[tauri::command]
203+
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+
}
250+
}
251+
252+
/// Remove the managed hook entry and delete the managed script.
253+
#[tauri::command]
254+
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+
}
306+
}

apps/desktop/src-tauri/src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod client;
88
pub mod client_install;
99
pub mod config_export;
1010
pub mod credential;
11+
pub mod cursor_hook_install;
1112
pub mod feature_members;
1213
pub mod feature_set;
1314
pub mod gateway;
@@ -31,6 +32,7 @@ pub use builtin_servers::*;
3132
pub use client::*;
3233
pub use client_install::*;
3334
pub use config_export::*;
35+
pub use cursor_hook_install::*;
3436
pub use feature_members::*;
3537
pub use feature_set::*;
3638
pub use gateway::*;

apps/desktop/src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,9 @@ pub fn run() {
10341034
commands::list_workspace_install_clients,
10351035
commands::generate_workspace_config_snippet,
10361036
commands::install_workspace_mcp_config,
1037+
commands::cursor_hook_status,
1038+
commands::install_cursor_hook,
1039+
commands::uninstall_cursor_hook,
10371040
// Workspace appearance commands
10381041
commands::list_workspace_appearances,
10391042
commands::upsert_workspace_appearance,

0 commit comments

Comments
 (0)