Skip to content

Commit 1ca8878

Browse files
committed
feat(desktop): install the Cursor workspace hook from Connections
Move the installer into the gateway so web admin can write ~/.cursor on the host via /api/v1/cursor-hook, and surface the same controls on every Cursor connection side panel. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 13bede1 commit 1ca8878

17 files changed

Lines changed: 600 additions & 480 deletions

File tree

Lines changed: 5 additions & 290 deletions
Original file line numberDiff line numberDiff line change
@@ -1,306 +1,21 @@
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.
62
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};
1704

1715
/// Current install state of the managed Cursor hook.
1726
#[tauri::command]
1737
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()
1999
}
20010

20111
/// Install or update the managed `preToolUse` hook.
20212
#[tauri::command]
20313
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()
25015
}
25116

25217
/// Remove the managed hook entry and delete the managed script.
25318
#[tauri::command]
25419
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()
30621
}

apps/desktop/src/features/clients/ClientsPage.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
Globe,
2929
ShieldOff,
3030
KeyRound,
31+
LocateFixed,
3132
} from 'lucide-react';
3233
import { ConnectIDEs } from '@/components/ConnectIDEs';
3334
import type { GatewayStatus, OAuthClient } from '@/lib/api/gateway';
@@ -42,6 +43,8 @@ import {
4243
} from '@/lib/api/gateway';
4344
import { RegisterApiKeyClientModal } from './RegisterApiKeyClientModal';
4445
import { ClientApiKeysSection } from './ClientApiKeysSection';
46+
import { CursorHookInstallSection } from './cursor-hook-install-section';
47+
import { isCursorConnection } from './cursor-bridge-config.helpers';
4548
import {
4649
isStarterFeatureSet,
4750
listFeatureSetsBySpace,
@@ -755,6 +758,20 @@ function SidePanel({
755758
</div>
756759
</section>
757760

761+
{isCursorConnection(client) && (
762+
<section className="rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--background))] p-4">
763+
<div className="flex items-start gap-3">
764+
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-[rgb(var(--accent))]/10">
765+
<LocateFixed className="h-5 w-5 text-[rgb(var(--accent))]" />
766+
</div>
767+
<div className="min-w-0 flex-1 space-y-3">
768+
<p className="text-sm font-semibold">{t('cursorBridge.hookTitle')}</p>
769+
<CursorHookInstallSection />
770+
</div>
771+
</div>
772+
</section>
773+
)}
774+
758775
{/* Per-client grants only matter for clients that explicitly do
759776
NOT declare the MCP `roots` capability — Claude.ai web,
760777
ChatGPT connectors, and similar rootless connectors. For

0 commit comments

Comments
 (0)