scone-cli 0.2.1

Scone: a local-first temporal memory engine — CLI, MCP server, and HTTP API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//! `scone setup <client>`: plug and play (gap-analysis P1). Zero
//! questions: detect the binary, write the config, say what happened.

use std::path::{Path, PathBuf};

/// How a client spells "here is a stdio MCP server". The clients agree on
/// the idea and disagree on every detail, so the shape is data.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Shape {
    /// `{"mcpServers": {"scone": {"command", "args"}}}`
    /// Claude Desktop, Cursor, Windsurf, Gemini CLI.
    McpServers,
    /// `{"servers": {"scone": {"type": "stdio", "command", "args"}}}`
    /// VS Code's mcp.json.
    VsCodeServers,
    /// `{"context_servers": {"scone": {"command", "args"}}}` in Zed's
    /// settings.json. `source` and `enabled` are not in the schema any
    /// more (verified against Zed 1.6.3's settings_content); they only
    /// survive because the enum is untagged and ignores extra keys.
    /// Variant selection is by shape, so `args` must always be present.
    ZedContextServers,
    /// `{"mcp": {"scone": {"type": "local", "command": [exe, args...]}}}`
    /// OpenCode folds the executable into one command array.
    OpenCodeMcp,
    /// `[mcp_servers.scone]` with `command` and `args`. Codex CLI's TOML.
    CodexToml,
}

/// The server entry itself, in whichever dialect the client reads.
fn entry(shape: Shape, exe: &Path, space: &str) -> serde_json::Value {
    let command = exe.display().to_string();
    let args = serde_json::json!(["--space", space, "mcp"]);
    match shape {
        Shape::McpServers => serde_json::json!({"command": command, "args": args}),
        Shape::VsCodeServers => {
            serde_json::json!({"type": "stdio", "command": command, "args": args})
        }
        Shape::ZedContextServers => serde_json::json!({"command": command, "args": args}),
        Shape::OpenCodeMcp => serde_json::json!({
            "type": "local",
            "command": [command, "--space", space, "mcp"],
            "enabled": true,
        }),
        Shape::CodexToml => serde_json::json!({"command": command, "args": args}),
    }
}

/// The top-level key a client keeps its servers under.
fn container(shape: Shape) -> &'static str {
    match shape {
        Shape::McpServers => "mcpServers",
        Shape::VsCodeServers => "servers",
        Shape::ZedContextServers => "context_servers",
        Shape::OpenCodeMcp => "mcp",
        Shape::CodexToml => "mcp_servers",
    }
}

/// Merge scone into any JSON-shaped client config, preserving everything
/// already there (other servers, unrelated settings, user edits).
/// Pure function, unit-tested against every shape.
pub fn merged_json_config(
    existing: &str,
    shape: Shape,
    exe: &Path,
    space: &str,
) -> Result<String, String> {
    let mut root: serde_json::Value = if existing.trim().is_empty() {
        serde_json::json!({})
    } else {
        serde_json::from_str(existing).map_err(|e| {
            format!(
                "existing config is not plain JSON ({e}); if it has comments, \
                 add scone by hand rather than let this command rewrite the \
                 file and drop them"
            )
        })?
    };
    if !root.is_object() {
        return Err("existing config is not a JSON object".into());
    }
    let key = container(shape);
    let servers = root
        .as_object_mut()
        .expect("checked object")
        .entry(key)
        .or_insert_with(|| serde_json::json!({}));
    if !servers.is_object() {
        return Err(format!("{key} is not an object"));
    }
    servers
        .as_object_mut()
        .expect("checked object")
        .insert("scone".to_owned(), entry(shape, exe, space));
    serde_json::to_string_pretty(&root).map_err(|e| e.to_string())
}

/// Merge scone into Codex CLI's TOML config, preserving the rest of the
/// file's tables. Pure function, unit-tested.
pub fn merged_toml_config(existing: &str, exe: &Path, space: &str) -> Result<String, String> {
    let mut root: toml::Table = if existing.trim().is_empty() {
        toml::Table::new()
    } else {
        existing
            .parse()
            .map_err(|e| format!("existing config is not TOML: {e}"))?
    };
    let servers = root
        .entry("mcp_servers")
        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
    let servers = servers
        .as_table_mut()
        .ok_or("mcp_servers is not a table in the existing config")?;
    let mut scone = toml::Table::new();
    scone.insert(
        "command".into(),
        toml::Value::String(exe.display().to_string()),
    );
    scone.insert(
        "args".into(),
        toml::Value::Array(vec![
            toml::Value::String("--space".into()),
            toml::Value::String(space.to_owned()),
            toml::Value::String("mcp".into()),
        ]),
    );
    servers.insert("scone".into(), toml::Value::Table(scone));
    toml::to_string_pretty(&root).map_err(|e| e.to_string())
}

/// Merge scone's MCP server entry into a Claude Desktop config, preserving
/// everything already there. Pure function, unit-tested.
pub fn merged_desktop_config(existing: &str, exe: &Path, space: &str) -> Result<String, String> {
    merged_json_config(existing, Shape::McpServers, exe, space)
}

/// A client scone can register itself with by editing a config file.
pub struct Client {
    /// What the user types after `scone setup`.
    pub name: &'static str,
    /// Config path relative to home. Used on Linux, and on macOS when
    /// `mac_rel_path` is None.
    pub rel_path: &'static str,
    /// macOS path when the client keeps config somewhere else there.
    pub mac_rel_path: Option<&'static str>,
    pub shape: Shape,
    /// Shown after a successful write.
    pub after: &'static str,
}

impl Client {
    /// Config path for the platform we are running on.
    pub fn rel_path(&self) -> &'static str {
        match (cfg!(target_os = "macos"), self.mac_rel_path) {
            (true, Some(mac)) => mac,
            _ => self.rel_path,
        }
    }
}

/// Every client whose config layout we have verified. Adding one is a
/// row here, not a new code path.
pub const CLIENTS: &[Client] = &[
    Client {
        name: "cursor",
        rel_path: ".cursor/mcp.json",
        mac_rel_path: None,
        shape: Shape::McpServers,
        after: "restart Cursor, then check Settings > MCP",
    },
    Client {
        name: "windsurf",
        rel_path: ".codeium/windsurf/mcp_config.json",
        mac_rel_path: None,
        shape: Shape::McpServers,
        after: "restart Windsurf and refresh MCP servers in Cascade (newer \
                Devin-agent tabs read their own config and may not see this)",
    },
    Client {
        name: "gemini-cli",
        rel_path: ".gemini/settings.json",
        mac_rel_path: None,
        shape: Shape::McpServers,
        after: "restart the gemini CLI",
    },
    Client {
        name: "zed",
        rel_path: ".config/zed/settings.json",
        mac_rel_path: None,
        shape: Shape::ZedContextServers,
        after: "restart Zed; scone appears under context servers",
    },
    Client {
        name: "codex",
        rel_path: ".codex/config.toml",
        mac_rel_path: None,
        shape: Shape::CodexToml,
        after: "restart the codex CLI",
    },
    Client {
        name: "vscode",
        rel_path: ".config/Code/User/mcp.json",
        mac_rel_path: Some("Library/Application Support/Code/User/mcp.json"),
        shape: Shape::VsCodeServers,
        after: "reload VS Code; scone is available in agent mode",
    },
    Client {
        name: "opencode",
        rel_path: ".config/opencode/opencode.json",
        mac_rel_path: None,
        shape: Shape::OpenCodeMcp,
        after: "restart opencode",
    },
    Client {
        name: "cline",
        rel_path: ".cline/data/settings/cline_mcp_settings.json",
        mac_rel_path: None,
        shape: Shape::McpServers,
        after: "restart the Cline extension (older builds read a legacy \
                globalStorage path instead; re-run setup after updating)",
    },
];

pub fn client_by_name(name: &str) -> Option<&'static Client> {
    CLIENTS.iter().find(|c| c.name == name)
}

/// Register scone with any known client: read what is there, merge, write
/// back. Never clobbers a config it cannot parse.
pub fn setup_client(client: &Client, space: &str) -> Result<String, String> {
    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
    let home = std::env::var_os("HOME").ok_or("cannot resolve HOME")?;
    let path = PathBuf::from(&home).join(client.rel_path());
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    let merged = match client.shape {
        Shape::CodexToml => merged_toml_config(&existing, &exe, space)?,
        shape => merged_json_config(&existing, shape, &exe, space)?,
    };
    std::fs::write(&path, merged).map_err(|e| e.to_string())?;
    Ok(format!(
        "wrote {}\n{} (space: {space})",
        path.display(),
        client.after
    ))
}

pub fn desktop_config_path() -> Result<PathBuf, String> {
    let home = std::env::var_os("HOME").ok_or("cannot resolve HOME")?;
    let base = if cfg!(target_os = "macos") {
        PathBuf::from(&home).join("Library/Application Support/Claude")
    } else {
        PathBuf::from(&home).join(".config/Claude")
    };
    Ok(base.join("claude_desktop_config.json"))
}

pub fn setup_claude_desktop(space: &str) -> Result<String, String> {
    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
    let path = desktop_config_path()?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    let merged = merged_desktop_config(&existing, &exe, space)?;
    std::fs::write(&path, merged).map_err(|e| e.to_string())?;
    Ok(format!(
        "wrote {}\nrestart Claude Desktop to pick up the scone memory server",
        path.display()
    ))
}

pub fn setup_claude_code(space: &str) -> Result<String, String> {
    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
    let output = std::process::Command::new("claude")
        .args([
            "mcp",
            "add",
            "scone",
            "--",
            &exe.display().to_string(),
            "--space",
            space,
            "mcp",
        ])
        .output()
        .map_err(|_| "the `claude` CLI is not on PATH; install Claude Code first".to_owned())?;
    if !output.status.success() {
        return Err(format!(
            "claude mcp add failed: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    Ok(format!(
        "registered the scone memory server with Claude Code (space: {space})"
    ))
}

/// Merge scone's hook wiring into a Claude Code settings.json, preserving
/// everything else. Pure function, unit-tested.
pub fn merged_settings_hooks(existing: &str, exe: &Path, space: &str) -> Result<String, String> {
    let mut root: serde_json::Value = if existing.trim().is_empty() {
        serde_json::json!({})
    } else {
        serde_json::from_str(existing).map_err(|e| format!("settings.json is not JSON: {e}"))?
    };
    if !root.is_object() {
        return Err("settings.json is not a JSON object".into());
    }
    let entry = |event: &str, timeout: u64| {
        serde_json::json!([{
            "hooks": [{
                "type": "command",
                "command": format!(
                    "{} --space {} hook {}",
                    exe.display(), space, event
                ),
                "timeout": timeout,
            }]
        }])
    };
    let hooks = root
        .as_object_mut()
        .expect("checked object")
        .entry("hooks")
        .or_insert_with(|| serde_json::json!({}));
    if !hooks.is_object() {
        return Err("hooks is not an object".into());
    }
    let hooks = hooks.as_object_mut().expect("checked object");
    hooks.insert("SessionStart".into(), entry("session-start", 10));
    hooks.insert("UserPromptSubmit".into(), entry("user-prompt", 10));
    hooks.insert("SessionEnd".into(), entry("session-end", 60));
    serde_json::to_string_pretty(&root).map_err(|e| e.to_string())
}

/// Wire this project's `.claude/settings.json` to the scone hook handlers.
pub fn setup_claude_code_hooks(space: &str) -> Result<String, String> {
    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
    let dir = std::env::current_dir()
        .map_err(|e| e.to_string())?
        .join(".claude");
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    let path = dir.join("settings.json");
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    let merged = merged_settings_hooks(&existing, &exe, space)?;
    std::fs::write(&path, merged).map_err(|e| e.to_string())?;
    Ok(format!(
        "wrote {}\nnew Claude Code sessions here get memory injection and capture (space: {space})",
        path.display()
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Every client's dialect, from an empty config and from one that
    /// already holds another server. A setup command that eats a user's
    /// existing MCP servers is worse than no setup command.
    #[test]
    fn every_shape_merges_without_losing_what_was_there() {
        let exe = Path::new("/usr/local/bin/scone");
        for client in CLIENTS {
            if client.shape == Shape::CodexToml {
                let existing = "[mcp_servers.other]\ncommand = \"other\"\nargs = []\n";
                let merged = merged_toml_config(existing, exe, "work").unwrap();
                let v: toml::Table = merged.parse().unwrap();
                let servers = v["mcp_servers"].as_table().unwrap();
                assert!(
                    servers.contains_key("other"),
                    "{}: dropped other",
                    client.name
                );
                assert_eq!(servers["scone"]["args"][1].as_str(), Some("work"));
                continue;
            }
            let key = container(client.shape);
            let existing = format!("{{\"{key}\": {{\"other\": {{\"command\": \"x\"}}}}}}");
            let merged = merged_json_config(&existing, client.shape, exe, "work").unwrap();
            let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
            assert!(
                v[key]["other"].is_object(),
                "{}: dropped the existing server",
                client.name
            );
            let command = &v[key]["scone"]["command"];
            let exe = command.as_str().or_else(|| command[0].as_str());
            assert_eq!(
                exe,
                Some("/usr/local/bin/scone"),
                "{}: no scone entry",
                client.name
            );
            if client.shape == Shape::OpenCodeMcp {
                // OpenCode folds the executable and its args into one array.
                assert_eq!(command[2].as_str(), Some("work"));
                assert_eq!(v[key]["scone"]["type"].as_str(), Some("local"));
            } else {
                assert_eq!(v[key]["scone"]["args"][1].as_str(), Some("work"));
            }
        }
    }

    #[test]
    fn vscode_and_zed_carry_their_required_fields() {
        let exe = Path::new("/usr/local/bin/scone");
        let vs = merged_json_config("", Shape::VsCodeServers, exe, "d").unwrap();
        let v: serde_json::Value = serde_json::from_str(&vs).unwrap();
        assert_eq!(v["servers"]["scone"]["type"].as_str(), Some("stdio"));
        let zed = merged_json_config("", Shape::ZedContextServers, exe, "d").unwrap();
        let z: serde_json::Value = serde_json::from_str(&zed).unwrap();
        // Zed picks its variant by SHAPE, so args must always be emitted,
        // and `source` left the schema; it only ever parsed by accident.
        let scone = &z["context_servers"]["scone"];
        assert!(
            scone["command"].is_string(),
            "command must be a plain string"
        );
        assert!(scone["args"].is_array(), "args must always be emitted");
        assert!(scone["source"].is_null(), "source is not in the schema");
    }

    #[test]
    fn merge_into_empty_and_invalid() {
        let merged =
            merged_desktop_config("", Path::new("/usr/local/bin/scone"), "default").unwrap();
        let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
        assert_eq!(v["mcpServers"]["scone"]["args"][2], "mcp");
        assert!(merged_desktop_config("[1,2]", Path::new("/x"), "d").is_err());
        assert!(merged_desktop_config("not json", Path::new("/x"), "d").is_err());
    }
}