1use std::path::{Path, PathBuf};
5
6#[derive(Clone, Copy, PartialEq, Eq, Debug)]
9pub enum Shape {
10 McpServers,
13 VsCodeServers,
16 ZedContextServers,
22 OpenCodeMcp,
25 CodexToml,
27}
28
29fn entry(shape: Shape, exe: &Path, space: &str) -> serde_json::Value {
31 let command = exe.display().to_string();
32 let args = serde_json::json!(["--space", space, "mcp"]);
33 match shape {
34 Shape::McpServers => serde_json::json!({"command": command, "args": args}),
35 Shape::VsCodeServers => {
36 serde_json::json!({"type": "stdio", "command": command, "args": args})
37 }
38 Shape::ZedContextServers => serde_json::json!({"command": command, "args": args}),
39 Shape::OpenCodeMcp => serde_json::json!({
40 "type": "local",
41 "command": [command, "--space", space, "mcp"],
42 "enabled": true,
43 }),
44 Shape::CodexToml => serde_json::json!({"command": command, "args": args}),
45 }
46}
47
48fn container(shape: Shape) -> &'static str {
50 match shape {
51 Shape::McpServers => "mcpServers",
52 Shape::VsCodeServers => "servers",
53 Shape::ZedContextServers => "context_servers",
54 Shape::OpenCodeMcp => "mcp",
55 Shape::CodexToml => "mcp_servers",
56 }
57}
58
59pub fn merged_json_config(
63 existing: &str,
64 shape: Shape,
65 exe: &Path,
66 space: &str,
67) -> Result<String, String> {
68 let mut root: serde_json::Value = if existing.trim().is_empty() {
69 serde_json::json!({})
70 } else {
71 serde_json::from_str(existing).map_err(|e| {
72 format!(
73 "existing config is not plain JSON ({e}); if it has comments, \
74 add scone by hand rather than let this command rewrite the \
75 file and drop them"
76 )
77 })?
78 };
79 if !root.is_object() {
80 return Err("existing config is not a JSON object".into());
81 }
82 let key = container(shape);
83 let servers = root
84 .as_object_mut()
85 .expect("checked object")
86 .entry(key)
87 .or_insert_with(|| serde_json::json!({}));
88 if !servers.is_object() {
89 return Err(format!("{key} is not an object"));
90 }
91 servers
92 .as_object_mut()
93 .expect("checked object")
94 .insert("scone".to_owned(), entry(shape, exe, space));
95 serde_json::to_string_pretty(&root).map_err(|e| e.to_string())
96}
97
98pub fn merged_toml_config(existing: &str, exe: &Path, space: &str) -> Result<String, String> {
101 let mut root: toml::Table = if existing.trim().is_empty() {
102 toml::Table::new()
103 } else {
104 existing
105 .parse()
106 .map_err(|e| format!("existing config is not TOML: {e}"))?
107 };
108 let servers = root
109 .entry("mcp_servers")
110 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
111 let servers = servers
112 .as_table_mut()
113 .ok_or("mcp_servers is not a table in the existing config")?;
114 let mut scone = toml::Table::new();
115 scone.insert(
116 "command".into(),
117 toml::Value::String(exe.display().to_string()),
118 );
119 scone.insert(
120 "args".into(),
121 toml::Value::Array(vec![
122 toml::Value::String("--space".into()),
123 toml::Value::String(space.to_owned()),
124 toml::Value::String("mcp".into()),
125 ]),
126 );
127 servers.insert("scone".into(), toml::Value::Table(scone));
128 toml::to_string_pretty(&root).map_err(|e| e.to_string())
129}
130
131pub fn merged_desktop_config(existing: &str, exe: &Path, space: &str) -> Result<String, String> {
134 merged_json_config(existing, Shape::McpServers, exe, space)
135}
136
137pub struct Client {
139 pub name: &'static str,
141 pub rel_path: &'static str,
144 pub mac_rel_path: Option<&'static str>,
146 pub shape: Shape,
147 pub after: &'static str,
149}
150
151impl Client {
152 pub fn rel_path(&self) -> &'static str {
154 match (cfg!(target_os = "macos"), self.mac_rel_path) {
155 (true, Some(mac)) => mac,
156 _ => self.rel_path,
157 }
158 }
159}
160
161pub const CLIENTS: &[Client] = &[
164 Client {
165 name: "cursor",
166 rel_path: ".cursor/mcp.json",
167 mac_rel_path: None,
168 shape: Shape::McpServers,
169 after: "restart Cursor, then check Settings > MCP",
170 },
171 Client {
172 name: "windsurf",
173 rel_path: ".codeium/windsurf/mcp_config.json",
174 mac_rel_path: None,
175 shape: Shape::McpServers,
176 after: "restart Windsurf and refresh MCP servers in Cascade (newer \
177 Devin-agent tabs read their own config and may not see this)",
178 },
179 Client {
180 name: "gemini-cli",
181 rel_path: ".gemini/settings.json",
182 mac_rel_path: None,
183 shape: Shape::McpServers,
184 after: "restart the gemini CLI",
185 },
186 Client {
187 name: "zed",
188 rel_path: ".config/zed/settings.json",
189 mac_rel_path: None,
190 shape: Shape::ZedContextServers,
191 after: "restart Zed; scone appears under context servers",
192 },
193 Client {
194 name: "codex",
195 rel_path: ".codex/config.toml",
196 mac_rel_path: None,
197 shape: Shape::CodexToml,
198 after: "restart the codex CLI",
199 },
200 Client {
201 name: "vscode",
202 rel_path: ".config/Code/User/mcp.json",
203 mac_rel_path: Some("Library/Application Support/Code/User/mcp.json"),
204 shape: Shape::VsCodeServers,
205 after: "reload VS Code; scone is available in agent mode",
206 },
207 Client {
208 name: "opencode",
209 rel_path: ".config/opencode/opencode.json",
210 mac_rel_path: None,
211 shape: Shape::OpenCodeMcp,
212 after: "restart opencode",
213 },
214 Client {
215 name: "cline",
216 rel_path: ".cline/data/settings/cline_mcp_settings.json",
217 mac_rel_path: None,
218 shape: Shape::McpServers,
219 after: "restart the Cline extension (older builds read a legacy \
220 globalStorage path instead; re-run setup after updating)",
221 },
222];
223
224pub fn client_by_name(name: &str) -> Option<&'static Client> {
225 CLIENTS.iter().find(|c| c.name == name)
226}
227
228pub fn setup_client(client: &Client, space: &str) -> Result<String, String> {
231 let exe = std::env::current_exe().map_err(|e| e.to_string())?;
232 let home = std::env::var_os("HOME").ok_or("cannot resolve HOME")?;
233 let path = PathBuf::from(&home).join(client.rel_path());
234 if let Some(parent) = path.parent() {
235 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
236 }
237 let existing = std::fs::read_to_string(&path).unwrap_or_default();
238 let merged = match client.shape {
239 Shape::CodexToml => merged_toml_config(&existing, &exe, space)?,
240 shape => merged_json_config(&existing, shape, &exe, space)?,
241 };
242 std::fs::write(&path, merged).map_err(|e| e.to_string())?;
243 Ok(format!(
244 "wrote {}\n{} (space: {space})",
245 path.display(),
246 client.after
247 ))
248}
249
250pub fn desktop_config_path() -> Result<PathBuf, String> {
251 let home = std::env::var_os("HOME").ok_or("cannot resolve HOME")?;
252 let base = if cfg!(target_os = "macos") {
253 PathBuf::from(&home).join("Library/Application Support/Claude")
254 } else {
255 PathBuf::from(&home).join(".config/Claude")
256 };
257 Ok(base.join("claude_desktop_config.json"))
258}
259
260pub fn setup_claude_desktop(space: &str) -> Result<String, String> {
261 let exe = std::env::current_exe().map_err(|e| e.to_string())?;
262 let path = desktop_config_path()?;
263 if let Some(parent) = path.parent() {
264 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
265 }
266 let existing = std::fs::read_to_string(&path).unwrap_or_default();
267 let merged = merged_desktop_config(&existing, &exe, space)?;
268 std::fs::write(&path, merged).map_err(|e| e.to_string())?;
269 Ok(format!(
270 "wrote {}\nrestart Claude Desktop to pick up the scone memory server",
271 path.display()
272 ))
273}
274
275pub fn setup_claude_code(space: &str) -> Result<String, String> {
276 let exe = std::env::current_exe().map_err(|e| e.to_string())?;
277 let output = std::process::Command::new("claude")
278 .args([
279 "mcp",
280 "add",
281 "scone",
282 "--",
283 &exe.display().to_string(),
284 "--space",
285 space,
286 "mcp",
287 ])
288 .output()
289 .map_err(|_| "the `claude` CLI is not on PATH; install Claude Code first".to_owned())?;
290 if !output.status.success() {
291 return Err(format!(
292 "claude mcp add failed: {}",
293 String::from_utf8_lossy(&output.stderr)
294 ));
295 }
296 Ok(format!(
297 "registered the scone memory server with Claude Code (space: {space})"
298 ))
299}
300
301pub fn merged_settings_hooks(existing: &str, exe: &Path, space: &str) -> Result<String, String> {
304 let mut root: serde_json::Value = if existing.trim().is_empty() {
305 serde_json::json!({})
306 } else {
307 serde_json::from_str(existing).map_err(|e| format!("settings.json is not JSON: {e}"))?
308 };
309 if !root.is_object() {
310 return Err("settings.json is not a JSON object".into());
311 }
312 let entry = |event: &str, timeout: u64| {
313 serde_json::json!([{
314 "hooks": [{
315 "type": "command",
316 "command": format!(
317 "{} --space {} hook {}",
318 exe.display(), space, event
319 ),
320 "timeout": timeout,
321 }]
322 }])
323 };
324 let hooks = root
325 .as_object_mut()
326 .expect("checked object")
327 .entry("hooks")
328 .or_insert_with(|| serde_json::json!({}));
329 if !hooks.is_object() {
330 return Err("hooks is not an object".into());
331 }
332 let hooks = hooks.as_object_mut().expect("checked object");
333 hooks.insert("SessionStart".into(), entry("session-start", 10));
334 hooks.insert("UserPromptSubmit".into(), entry("user-prompt", 10));
335 hooks.insert("SessionEnd".into(), entry("session-end", 60));
336 serde_json::to_string_pretty(&root).map_err(|e| e.to_string())
337}
338
339pub fn setup_claude_code_hooks(space: &str) -> Result<String, String> {
341 let exe = std::env::current_exe().map_err(|e| e.to_string())?;
342 let dir = std::env::current_dir()
343 .map_err(|e| e.to_string())?
344 .join(".claude");
345 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
346 let path = dir.join("settings.json");
347 let existing = std::fs::read_to_string(&path).unwrap_or_default();
348 let merged = merged_settings_hooks(&existing, &exe, space)?;
349 std::fs::write(&path, merged).map_err(|e| e.to_string())?;
350 Ok(format!(
351 "wrote {}\nnew Claude Code sessions here get memory injection and capture (space: {space})",
352 path.display()
353 ))
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
364 fn every_shape_merges_without_losing_what_was_there() {
365 let exe = Path::new("/usr/local/bin/scone");
366 for client in CLIENTS {
367 if client.shape == Shape::CodexToml {
368 let existing = "[mcp_servers.other]\ncommand = \"other\"\nargs = []\n";
369 let merged = merged_toml_config(existing, exe, "work").unwrap();
370 let v: toml::Table = merged.parse().unwrap();
371 let servers = v["mcp_servers"].as_table().unwrap();
372 assert!(
373 servers.contains_key("other"),
374 "{}: dropped other",
375 client.name
376 );
377 assert_eq!(servers["scone"]["args"][1].as_str(), Some("work"));
378 continue;
379 }
380 let key = container(client.shape);
381 let existing = format!("{{\"{key}\": {{\"other\": {{\"command\": \"x\"}}}}}}");
382 let merged = merged_json_config(&existing, client.shape, exe, "work").unwrap();
383 let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
384 assert!(
385 v[key]["other"].is_object(),
386 "{}: dropped the existing server",
387 client.name
388 );
389 let command = &v[key]["scone"]["command"];
390 let exe = command.as_str().or_else(|| command[0].as_str());
391 assert_eq!(
392 exe,
393 Some("/usr/local/bin/scone"),
394 "{}: no scone entry",
395 client.name
396 );
397 if client.shape == Shape::OpenCodeMcp {
398 assert_eq!(command[2].as_str(), Some("work"));
400 assert_eq!(v[key]["scone"]["type"].as_str(), Some("local"));
401 } else {
402 assert_eq!(v[key]["scone"]["args"][1].as_str(), Some("work"));
403 }
404 }
405 }
406
407 #[test]
408 fn vscode_and_zed_carry_their_required_fields() {
409 let exe = Path::new("/usr/local/bin/scone");
410 let vs = merged_json_config("", Shape::VsCodeServers, exe, "d").unwrap();
411 let v: serde_json::Value = serde_json::from_str(&vs).unwrap();
412 assert_eq!(v["servers"]["scone"]["type"].as_str(), Some("stdio"));
413 let zed = merged_json_config("", Shape::ZedContextServers, exe, "d").unwrap();
414 let z: serde_json::Value = serde_json::from_str(&zed).unwrap();
415 let scone = &z["context_servers"]["scone"];
418 assert!(
419 scone["command"].is_string(),
420 "command must be a plain string"
421 );
422 assert!(scone["args"].is_array(), "args must always be emitted");
423 assert!(scone["source"].is_null(), "source is not in the schema");
424 }
425
426 #[test]
427 fn merge_into_empty_and_invalid() {
428 let merged =
429 merged_desktop_config("", Path::new("/usr/local/bin/scone"), "default").unwrap();
430 let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
431 assert_eq!(v["mcpServers"]["scone"]["args"][2], "mcp");
432 assert!(merged_desktop_config("[1,2]", Path::new("/x"), "d").is_err());
433 assert!(merged_desktop_config("not json", Path::new("/x"), "d").is_err());
434 }
435}