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/// Merge scone's MCP server entry into a Claude Desktop config, preserving
7/// everything already there. Pure function, unit-tested.
8pub fn merged_desktop_config(existing: &str, exe: &Path, space: &str) -> Result<String, String> {
9    let mut root: serde_json::Value = if existing.trim().is_empty() {
10        serde_json::json!({})
11    } else {
12        serde_json::from_str(existing).map_err(|e| format!("existing config is not JSON: {e}"))?
13    };
14    if !root.is_object() {
15        return Err("existing config is not a JSON object".into());
16    }
17    let servers = root
18        .as_object_mut()
19        .expect("checked object")
20        .entry("mcpServers")
21        .or_insert_with(|| serde_json::json!({}));
22    if !servers.is_object() {
23        return Err("mcpServers is not an object".into());
24    }
25    servers.as_object_mut().expect("checked object").insert(
26        "scone".to_owned(),
27        serde_json::json!({
28            "command": exe.display().to_string(),
29            "args": ["--space", space, "mcp"],
30        }),
31    );
32    serde_json::to_string_pretty(&root).map_err(|e| e.to_string())
33}
34
35pub fn desktop_config_path() -> Result<PathBuf, String> {
36    let home = std::env::var_os("HOME").ok_or("cannot resolve HOME")?;
37    let base = if cfg!(target_os = "macos") {
38        PathBuf::from(&home).join("Library/Application Support/Claude")
39    } else {
40        PathBuf::from(&home).join(".config/Claude")
41    };
42    Ok(base.join("claude_desktop_config.json"))
43}
44
45pub fn setup_claude_desktop(space: &str) -> Result<String, String> {
46    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
47    let path = desktop_config_path()?;
48    if let Some(parent) = path.parent() {
49        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
50    }
51    let existing = std::fs::read_to_string(&path).unwrap_or_default();
52    let merged = merged_desktop_config(&existing, &exe, space)?;
53    std::fs::write(&path, merged).map_err(|e| e.to_string())?;
54    Ok(format!(
55        "wrote {}\nrestart Claude Desktop to pick up the scone memory server",
56        path.display()
57    ))
58}
59
60pub fn setup_claude_code(space: &str) -> Result<String, String> {
61    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
62    let output = std::process::Command::new("claude")
63        .args([
64            "mcp",
65            "add",
66            "scone",
67            "--",
68            &exe.display().to_string(),
69            "--space",
70            space,
71            "mcp",
72        ])
73        .output()
74        .map_err(|_| "the `claude` CLI is not on PATH; install Claude Code first".to_owned())?;
75    if !output.status.success() {
76        return Err(format!(
77            "claude mcp add failed: {}",
78            String::from_utf8_lossy(&output.stderr)
79        ));
80    }
81    Ok(format!(
82        "registered the scone memory server with Claude Code (space: {space})"
83    ))
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn merge_into_empty_and_invalid() {
92        let merged =
93            merged_desktop_config("", Path::new("/usr/local/bin/scone"), "default").unwrap();
94        let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
95        assert_eq!(v["mcpServers"]["scone"]["args"][2], "mcp");
96        assert!(merged_desktop_config("[1,2]", Path::new("/x"), "d").is_err());
97        assert!(merged_desktop_config("not json", Path::new("/x"), "d").is_err());
98    }
99}