diff --git a/_primitives/_rust/keisei/src/adapters/claude_code.rs b/_primitives/_rust/keisei/src/adapters/claude_code.rs index bc08638..465c8c9 100644 --- a/_primitives/_rust/keisei/src/adapters/claude_code.rs +++ b/_primitives/_rust/keisei/src/adapters/claude_code.rs @@ -1,19 +1,19 @@ //! Claude Code adapter — writes MCP server entry into -//! `~/.claude/settings.json`. Config shape merges under -//! `mcpServers.keisei` so we never clobber unrelated entries. +//! `~/.claude/settings.json` under `mcpServers.keisei`. //! -//! Detection: `$CWD/.claude/settings.json` exists OR +//! Detection: `$CWD/.claude/settings.json` is a file OR //! `$KEISEI_HOME/.claude` (or `$HOME/.claude`) is a directory. //! `$KEISEI_HOME` overrides `$HOME` for tests. use crate::adapter::ClientAdapter; +use crate::adapters::jsonmcp::{keisei_entry, load_json_or_empty, merge_named, remove_named}; use crate::brain::Brain; use crate::error::Result; use crate::fsx::write_atomic; -use serde_json::{json, Map, Value}; use std::path::PathBuf; -pub const MCP_ENTRY_KEY: &str = "keisei"; +pub const PARENT_KEY: &str = "mcpServers"; +pub const ENTRY_KEY: &str = "keisei"; pub struct ClaudeCodeAdapter; @@ -56,7 +56,7 @@ impl ClientAdapter for ClaudeCodeAdapter { std::fs::create_dir_all(parent)?; } let mut doc = load_json_or_empty(&cfg)?; - merge_mcp_entry(&mut doc, brain); + merge_named(&mut doc, PARENT_KEY, ENTRY_KEY, keisei_entry(brain)); write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?) } @@ -66,7 +66,7 @@ impl ClientAdapter for ClaudeCodeAdapter { return Ok(()); } let mut doc = load_json_or_empty(&cfg)?; - remove_mcp_entry(&mut doc); + remove_named(&mut doc, PARENT_KEY, ENTRY_KEY); write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?) } @@ -74,54 +74,3 @@ impl ClientAdapter for ClaudeCodeAdapter { self.user_config_dir().join("settings.json") } } - -fn load_json_or_empty(cfg: &std::path::Path) -> Result { - if !cfg.is_file() { - return Ok(json!({})); - } - let raw = std::fs::read_to_string(cfg)?; - if raw.trim().is_empty() { - return Ok(json!({})); - } - Ok(serde_json::from_str(&raw)?) -} - -fn merge_mcp_entry(doc: &mut Value, brain: &Brain) { - if !doc.is_object() { - *doc = json!({}); - } - let obj = doc.as_object_mut().expect("doc is object after guard"); - let servers = obj - .entry("mcpServers".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - if !servers.is_object() { - *servers = Value::Object(Map::new()); - } - let entry = json!({ - "command": brain.mcp_server_path().to_string_lossy(), - "args": [], - "env": { - "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(), - "KEISEI_BRAIN_NAME": brain.name(), - } - }); - servers - .as_object_mut() - .expect("servers is object") - .insert(MCP_ENTRY_KEY.to_string(), entry); -} - -fn remove_mcp_entry(doc: &mut Value) { - if !doc.is_object() { - return; - } - let obj = doc.as_object_mut().expect("doc is object after guard"); - if let Some(servers) = obj.get_mut("mcpServers") { - if let Some(map) = servers.as_object_mut() { - map.remove(MCP_ENTRY_KEY); - if map.is_empty() { - obj.remove("mcpServers"); - } - } - } -} diff --git a/_primitives/_rust/keisei/src/adapters/continue_adapter.rs b/_primitives/_rust/keisei/src/adapters/continue_adapter.rs index 31805c4..a52a09b 100644 --- a/_primitives/_rust/keisei/src/adapters/continue_adapter.rs +++ b/_primitives/_rust/keisei/src/adapters/continue_adapter.rs @@ -1,27 +1,14 @@ //! Continue.dev adapter — writes MCP server entry into `~/.continue/`. //! -//! Config path strategy [UNVERIFIED — see note]: -//! 1. If `~/.continue/config.yaml` exists → YAML mode -//! 2. Else if `~/.continue/config.json` exists → JSON mode -//! 3. Else if `~/.continue/` exists → create `config.yaml` fresh -//! 4. Else `detect()` returns false (graceful) -//! -//! Schema (both forms), under top-level `mcpServers`: +//! Picks YAML if `config.yaml` exists, JSON if `config.json` exists, +//! else defaults to fresh `config.yaml`. Detection fires on `~/.continue/` +//! dir. Schema [UNVERIFIED — list-form under `mcpServers`]: //! ```yaml //! mcpServers: //! - name: keisei -//! command: /path/to/kei-mcp-server +//! command: ... //! args: [] -//! env: -//! KEISEI_BRAIN_ROOT: /Volumes/Brain1 //! ``` -//! -//! NOTE: Continue's exact MCP/plugin schema is [UNVERIFIED] in this -//! session. The adapter uses a list-form `mcpServers` which matches the -//! v0.18 prototypes and the Continue `config.yaml` conventions observed -//! in the public docs. If the live schema diverges, update this module. -//! -//! Detach preserves all other config keys and all non-keisei servers. use crate::adapter::ClientAdapter; use crate::brain::Brain; @@ -63,7 +50,6 @@ impl ContinueAdapter { } else if json.is_file() { (Form::Json, json) } else { - // Default for a fresh install: YAML (Continue's preferred form). (Form::Yaml, yaml) } } @@ -109,9 +95,6 @@ impl ClientAdapter for ContinueAdapter { } } -/// Load doc as a generic `serde_json::Value`. YAML → Value via serde_yaml, -/// JSON → Value directly. Unifying on `Value` keeps the merge logic form- -/// independent. fn load_doc(cfg: &std::path::Path, form: Form) -> Result { if !cfg.is_file() { return Ok(json!({})); @@ -120,14 +103,13 @@ fn load_doc(cfg: &std::path::Path, form: Form) -> Result { if raw.trim().is_empty() { return Ok(json!({})); } - let parsed: Value = match form { + match form { Form::Yaml => serde_yaml::from_str(&raw).map_err(|e| Error::ConfigParseError { path: cfg.to_path_buf(), reason: e.to_string(), - })?, - Form::Json => serde_json::from_str(&raw)?, - }; - Ok(parsed) + }), + Form::Json => Ok(serde_json::from_str(&raw)?), + } } fn write_doc(cfg: &std::path::Path, form: Form, doc: &Value) -> Result<()> { diff --git a/_primitives/_rust/keisei/src/adapters/cursor.rs b/_primitives/_rust/keisei/src/adapters/cursor.rs index 29ffcaf..6d538ca 100644 --- a/_primitives/_rust/keisei/src/adapters/cursor.rs +++ b/_primitives/_rust/keisei/src/adapters/cursor.rs @@ -1,18 +1,19 @@ -//! Cursor adapter — writes MCP server entry to Cursor's MCP config. +//! Cursor adapter — writes MCP entry under `mcpServers.keisei`. //! -//! Config path: `$CWD/.cursor/mcp.json` if the project-local `.cursor/` -//! dir exists, else `~/.cursor/mcp.json`. Detection fires if either dir -//! exists. Schema [UNVERIFIED — matches Claude Desktop MCP convention]: +//! Path: `$CWD/.cursor/mcp.json` if the project-local `.cursor/` exists, +//! else `~/.cursor/mcp.json`. Detection fires on either dir. Schema +//! [UNVERIFIED — matches Claude Desktop MCP convention]: //! `{ "mcpServers": { "keisei": { "command": "...", "args": [] } } }`. use crate::adapter::ClientAdapter; +use crate::adapters::jsonmcp::{keisei_entry, load_json_or_empty, merge_named, remove_named}; use crate::brain::Brain; use crate::error::Result; use crate::fsx::write_atomic; -use serde_json::{json, Map, Value}; use std::path::PathBuf; -pub const MCP_ENTRY_KEY: &str = "keisei"; +pub const PARENT_KEY: &str = "mcpServers"; +pub const ENTRY_KEY: &str = "keisei"; pub struct CursorAdapter; @@ -69,7 +70,7 @@ impl ClientAdapter for CursorAdapter { std::fs::create_dir_all(parent)?; } let mut doc = load_json_or_empty(&cfg)?; - merge_entry(&mut doc, brain); + merge_named(&mut doc, PARENT_KEY, ENTRY_KEY, keisei_entry(brain)); write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?) } @@ -79,7 +80,7 @@ impl ClientAdapter for CursorAdapter { return Ok(()); } let mut doc = load_json_or_empty(&cfg)?; - remove_entry(&mut doc); + remove_named(&mut doc, PARENT_KEY, ENTRY_KEY); write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?) } @@ -87,54 +88,3 @@ impl ClientAdapter for CursorAdapter { self.pick_config_path() } } - -fn load_json_or_empty(cfg: &std::path::Path) -> Result { - if !cfg.is_file() { - return Ok(json!({})); - } - let raw = std::fs::read_to_string(cfg)?; - if raw.trim().is_empty() { - return Ok(json!({})); - } - Ok(serde_json::from_str(&raw)?) -} - -fn merge_entry(doc: &mut Value, brain: &Brain) { - if !doc.is_object() { - *doc = json!({}); - } - let obj = doc.as_object_mut().expect("doc is object after guard"); - let servers = obj - .entry("mcpServers".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - if !servers.is_object() { - *servers = Value::Object(Map::new()); - } - let entry = json!({ - "command": brain.mcp_server_path().to_string_lossy(), - "args": [], - "env": { - "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(), - "KEISEI_BRAIN_NAME": brain.name(), - } - }); - servers - .as_object_mut() - .expect("servers is object") - .insert(MCP_ENTRY_KEY.to_string(), entry); -} - -fn remove_entry(doc: &mut Value) { - if !doc.is_object() { - return; - } - let obj = doc.as_object_mut().expect("doc is object after guard"); - if let Some(servers) = obj.get_mut("mcpServers") { - if let Some(map) = servers.as_object_mut() { - map.remove(MCP_ENTRY_KEY); - if map.is_empty() { - obj.remove("mcpServers"); - } - } - } -} diff --git a/_primitives/_rust/keisei/src/adapters/jsonmcp.rs b/_primitives/_rust/keisei/src/adapters/jsonmcp.rs new file mode 100644 index 0000000..a383206 --- /dev/null +++ b/_primitives/_rust/keisei/src/adapters/jsonmcp.rs @@ -0,0 +1,70 @@ +//! Shared JSON-merge helpers for MCP-style adapters (claude-code, cursor, +//! zed). Same algorithm, different parent/entry keys. +//! +//! Constructor Pattern: single responsibility — merge / remove one named +//! server under one named parent, preserving every other key. + +use crate::brain::Brain; +use crate::error::Result; +use serde_json::{json, Map, Value}; + +/// Load `cfg` as JSON, or return empty `{}` if missing/blank. JSON parse +/// errors propagate via `Error::Json`. +pub fn load_json_or_empty(cfg: &std::path::Path) -> Result { + if !cfg.is_file() { + return Ok(json!({})); + } + let raw = std::fs::read_to_string(cfg)?; + if raw.trim().is_empty() { + return Ok(json!({})); + } + Ok(serde_json::from_str(&raw)?) +} + +/// Build the keisei MCP entry value. +pub fn keisei_entry(brain: &Brain) -> Value { + json!({ + "command": brain.mcp_server_path().to_string_lossy(), + "args": [], + "env": { + "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(), + "KEISEI_BRAIN_NAME": brain.name(), + } + }) +} + +/// Insert `entry` under `doc[parent_key][entry_key]`, creating both keys +/// as objects if absent. Existing siblings are preserved. +pub fn merge_named(doc: &mut Value, parent_key: &str, entry_key: &str, entry: Value) { + if !doc.is_object() { + *doc = json!({}); + } + let obj = doc.as_object_mut().expect("doc object"); + let parent = obj + .entry(parent_key.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !parent.is_object() { + *parent = Value::Object(Map::new()); + } + parent + .as_object_mut() + .expect("parent object") + .insert(entry_key.to_string(), entry); +} + +/// Remove `doc[parent_key][entry_key]`. If parent ends up empty, remove +/// parent too (no vestigial `{}` in the config). +pub fn remove_named(doc: &mut Value, parent_key: &str, entry_key: &str) { + if !doc.is_object() { + return; + } + let obj = doc.as_object_mut().expect("doc object"); + if let Some(parent) = obj.get_mut(parent_key) { + if let Some(map) = parent.as_object_mut() { + map.remove(entry_key); + if map.is_empty() { + obj.remove(parent_key); + } + } + } +} diff --git a/_primitives/_rust/keisei/src/adapters/mod.rs b/_primitives/_rust/keisei/src/adapters/mod.rs index 8bdf00f..45db347 100644 --- a/_primitives/_rust/keisei/src/adapters/mod.rs +++ b/_primitives/_rust/keisei/src/adapters/mod.rs @@ -1,9 +1,11 @@ //! Concrete `ClientAdapter` implementations, one file per client. //! //! Constructor Pattern: this file is the module declaration hub only — -//! no logic lives here. Each adapter owns its own file. +//! no logic lives here. `jsonmcp` owns the shared JSON merge helpers +//! used by every JSON-keyed adapter (claude-code, cursor, zed). pub mod claude_code; pub mod continue_adapter; pub mod cursor; +pub mod jsonmcp; pub mod zed; diff --git a/_primitives/_rust/keisei/src/adapters/zed.rs b/_primitives/_rust/keisei/src/adapters/zed.rs index 8a16db5..e915384 100644 --- a/_primitives/_rust/keisei/src/adapters/zed.rs +++ b/_primitives/_rust/keisei/src/adapters/zed.rs @@ -1,40 +1,20 @@ -//! Zed adapter — writes MCP/context-server entry into Zed settings. +//! Zed adapter — writes context-server entry under `context_servers.keisei`. //! -//! Config path [UNVERIFIED for exact schema key-name]: -//! - macOS: `~/Library/Application Support/Zed/settings.json` -//! - Linux: `~/.config/zed/settings.json` -//! - Windows: not supported in this adapter (Zed Windows is preview) -//! -//! Schema (under a top-level `context_servers` object): -//! ```json -//! { -//! "context_servers": { -//! "keisei": { -//! "command": "/path/to/kei-mcp-server", -//! "args": [], -//! "env": { "KEISEI_BRAIN_ROOT": "..." } -//! } -//! } -//! } -//! ``` -//! -//! NOTE: Zed's `context_servers` key is the documented extension point for -//! MCP at time of writing — but the full schema (arg handling, -//! environment) is [UNVERIFIED] in this session. If a future Zed release -//! diverges, update this module. -//! -//! Detach preserves all other settings and all non-keisei context servers. -//! If the settings file is not valid JSON (Zed historically permitted -//! JSONC / comments), attach degrades gracefully by returning -//! `ConfigParseError` — the user can then switch to manual config. +//! Paths: `~/Library/Application Support/Zed/settings.json` on macOS, +//! `~/.config/zed/settings.json` on Linux. Detection fires on the +//! platform settings dir. Schema [UNVERIFIED]: +//! `{ "context_servers": { "keisei": { "command": "...", "args": [] } } }`. +//! Invalid JSON (e.g. JSONC with comments) → `ConfigParseError`. use crate::adapter::ClientAdapter; +use crate::adapters::jsonmcp::{keisei_entry, merge_named, remove_named}; use crate::brain::Brain; use crate::error::{Error, Result}; use crate::fsx::write_atomic; -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; use std::path::PathBuf; +pub const PARENT_KEY: &str = "context_servers"; pub const ENTRY_KEY: &str = "keisei"; pub struct ZedAdapter; @@ -56,10 +36,6 @@ impl ZedAdapter { base.join(".config/zed") } } - - fn settings_file(&self) -> PathBuf { - self.settings_dir().join("settings.json") - } } impl Default for ZedAdapter { @@ -82,10 +58,9 @@ impl ClientAdapter for ZedAdapter { if let Some(parent) = cfg.parent() { std::fs::create_dir_all(parent)?; } - let mut doc = load_json_or_empty(&cfg)?; - merge_entry(&mut doc, brain); - write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?)?; - Ok(()) + let mut doc = load_strict_json(&cfg)?; + merge_named(&mut doc, PARENT_KEY, ENTRY_KEY, keisei_entry(brain)); + write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?) } fn detach(&self) -> Result<()> { @@ -93,18 +68,19 @@ impl ClientAdapter for ZedAdapter { if !cfg.is_file() { return Ok(()); } - let mut doc = load_json_or_empty(&cfg)?; - remove_entry(&mut doc); - write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?)?; - Ok(()) + let mut doc = load_strict_json(&cfg)?; + remove_named(&mut doc, PARENT_KEY, ENTRY_KEY); + write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?) } fn config_path(&self) -> PathBuf { - self.settings_file() + self.settings_dir().join("settings.json") } } -fn load_json_or_empty(cfg: &std::path::Path) -> Result { +/// Zed historically permitted JSONC. We require strict JSON and surface a +/// `ConfigParseError` pointing at the file if the user has comments. +fn load_strict_json(cfg: &std::path::Path) -> Result { if !cfg.is_file() { return Ok(json!({})); } @@ -117,43 +93,3 @@ fn load_json_or_empty(cfg: &std::path::Path) -> Result { reason: e.to_string(), }) } - -fn merge_entry(doc: &mut Value, brain: &Brain) { - if !doc.is_object() { - *doc = json!({}); - } - let obj = doc.as_object_mut().expect("doc is object after guard"); - let servers = obj - .entry("context_servers".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - if !servers.is_object() { - *servers = Value::Object(Map::new()); - } - let entry = json!({ - "command": brain.mcp_server_path().to_string_lossy(), - "args": [], - "env": { - "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(), - "KEISEI_BRAIN_NAME": brain.name(), - } - }); - servers - .as_object_mut() - .expect("servers is object") - .insert(ENTRY_KEY.to_string(), entry); -} - -fn remove_entry(doc: &mut Value) { - if !doc.is_object() { - return; - } - let obj = doc.as_object_mut().expect("doc is object after guard"); - if let Some(servers) = obj.get_mut("context_servers") { - if let Some(map) = servers.as_object_mut() { - map.remove(ENTRY_KEY); - if map.is_empty() { - obj.remove("context_servers"); - } - } - } -} diff --git a/_primitives/_rust/keisei/src/error.rs b/_primitives/_rust/keisei/src/error.rs index 071b6c5..9879ee3 100644 --- a/_primitives/_rust/keisei/src/error.rs +++ b/_primitives/_rust/keisei/src/error.rs @@ -23,6 +23,7 @@ pub enum Error { NotAttached, #[error("adapter '{client}' failed: {reason}")] + #[allow(dead_code)] // surfaced by mount/detach orchestration; reserved for library consumers AdapterFailed { client: String, reason: String }, #[error("config parse error at {path}: {reason}")] diff --git a/_primitives/_rust/keisei/src/list.rs b/_primitives/_rust/keisei/src/list.rs index de52a86..a875543 100644 --- a/_primitives/_rust/keisei/src/list.rs +++ b/_primitives/_rust/keisei/src/list.rs @@ -27,29 +27,11 @@ struct Row { } fn print_table(rows: &[Row]) { - let name_w = header_or_max(rows, "adapter", |r| r.name.len()); - let det_w = "detected".len(); - println!( - "{: usize>(rows: &[Row], header: &str, f: F) -> usize { - rows.iter().map(f).max().unwrap_or(0).max(header.len()) -}