Skip to main content

scone/
setup.rs

1//! `scone setup <client>`: plug and play (gap-analysis P1). Zero
2//! questions: detect the binary, write the config, say what happened.
3
4use std::path::{Path, PathBuf};
5
6/// How a client spells "here is a stdio MCP server". The clients agree on
7/// the idea and disagree on every detail, so the shape is data.
8#[derive(Clone, Copy, PartialEq, Eq, Debug)]
9pub enum Shape {
10    /// `{"mcpServers": {"scone": {"command", "args"}}}`
11    /// Claude Desktop, Cursor, Windsurf, Gemini CLI.
12    McpServers,
13    /// `{"servers": {"scone": {"type": "stdio", "command", "args"}}}`
14    /// VS Code's mcp.json.
15    VsCodeServers,
16    /// `{"context_servers": {"scone": {"command", "args"}}}` in Zed's
17    /// settings.json. `source` and `enabled` are not in the schema any
18    /// more (verified against Zed 1.6.3's settings_content); they only
19    /// survive because the enum is untagged and ignores extra keys.
20    /// Variant selection is by shape, so `args` must always be present.
21    ZedContextServers,
22    /// `{"mcp": {"scone": {"type": "local", "command": [exe, args...]}}}`
23    /// OpenCode folds the executable into one command array.
24    OpenCodeMcp,
25    /// `[mcp_servers.scone]` with `command` and `args`. Codex CLI's TOML.
26    CodexToml,
27}
28
29/// The server entry itself, in whichever dialect the client reads.
30fn 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
48/// The top-level key a client keeps its servers under.
49fn 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
59/// Merge scone into any JSON-shaped client config, preserving everything
60/// already there (other servers, unrelated settings, user edits).
61/// Pure function, unit-tested against every shape.
62pub 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
98/// Merge scone into Codex CLI's TOML config, preserving the rest of the
99/// file's tables. Pure function, unit-tested.
100pub 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
131/// Merge scone's MCP server entry into a Claude Desktop config, preserving
132/// everything already there. Pure function, unit-tested.
133pub fn merged_desktop_config(existing: &str, exe: &Path, space: &str) -> Result<String, String> {
134    merged_json_config(existing, Shape::McpServers, exe, space)
135}
136
137/// A client scone can register itself with by editing a config file.
138pub struct Client {
139    /// What the user types after `scone setup`.
140    pub name: &'static str,
141    /// Config path relative to home. Used on Linux, and on macOS when
142    /// `mac_rel_path` is None.
143    pub rel_path: &'static str,
144    /// macOS path when the client keeps config somewhere else there.
145    pub mac_rel_path: Option<&'static str>,
146    pub shape: Shape,
147    /// Shown after a successful write.
148    pub after: &'static str,
149}
150
151impl Client {
152    /// Config path for the platform we are running on.
153    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
161/// Every client whose config layout we have verified. Adding one is a
162/// row here, not a new code path.
163pub 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
228/// Register scone with any known client: read what is there, merge, write
229/// back. Never clobbers a config it cannot parse.
230pub 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
301/// Merge scone's hook wiring into a Claude Code settings.json, preserving
302/// everything else. Pure function, unit-tested.
303pub 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
339/// Wire this project's `.claude/settings.json` to the scone hook handlers.
340pub 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    /// Every client's dialect, from an empty config and from one that
361    /// already holds another server. A setup command that eats a user's
362    /// existing MCP servers is worse than no setup command.
363    #[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                // OpenCode folds the executable and its args into one array.
399                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        // Zed picks its variant by SHAPE, so args must always be emitted,
416        // and `source` left the schema; it only ever parsed by accident.
417        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}