diff --git a/CHANGELOG.md b/CHANGELOG.md index c1f6042..b256add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ _primitives/_rust/target/release/kei-changelog \ ### Added - **primitives:** `keisei` CLI MVP — `attach ` + `status` subcommands for mounting a portable exobrain directory into Claude Code. First step of the v0.18 exobrain architecture (multi-client adapter surface prepared; only `claude-code` adapter ships in MVP). +- **primitives (v0.19 — multi-client exobrain):** + - `keisei mount ` — attach a brain to EVERY detected AI client in one shot (Claude Code + Cursor + Continue + Zed). + - `keisei detach` — remove the brain from every client recorded in the marker, preserving user's other MCP/context-server entries. + - `keisei list-adapters` — tabular dump of every registered adapter and whether it's detected on this host. + - 3 new `ClientAdapter` implementations: `cursor` (`.cursor/mcp.json` project-local or `~/.cursor/mcp.json` global), `continue` (`~/.continue/config.{yaml,json}` — YAML preferred, JSON fallback), `zed` (`~/Library/Application Support/Zed/settings.json` on macOS or `~/.config/zed/settings.json` on Linux, under `context_servers`). + - `keisei-attached.toml` schema **v2** — carries a list of `[[attachments]]` (client_type + config_path) instead of a single `client_type`. v1 markers read transparently (auto-migrated in memory). + - New error variants: `AdapterFailed { client, reason }` and `ConfigParseError { path, reason }`. - Placeholder: CHANGELOG.md generation wired through `kei-changelog` (this file). - Placeholder: `.github/workflows/release.yml` — tag-driven multi-platform release. - Placeholder: pre-built-binary install path in `install.sh` (`KEI_SKIP_RUST_BUILD=1`). diff --git a/README.md b/README.md index 8bb3c6a..9a218ce 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ Requires the new `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`, | `kei-refactor-engine` | v0.13.0 — consumes `kei-conflict-scan` JSON; emits plan markdown + auto-resolve review markdown (NOT a unified diff; v0.14.1 retraction) | | `kei-graph-check` | v0.13.0 — post-refactor wikilink + handoff + block-ref resolver gate | | `kei-store` | v0.13.0 — memory-repo backend abstraction (GitHub / Forgejo / Gitea / Filesystem / S3) | -| `keisei` | v0.18.0 — exobrain `attach` / `status` CLI (MVP: Claude Code) — mounts a portable brain into an AI client | +| `keisei` | v0.19.0 — exobrain multi-client CLI — `attach` / `mount` / `detach` / `status` / `list-adapters`. Mounts a portable brain into every detected AI client in one shot. Supported clients: Claude Code, Cursor, Continue, Zed. `mount` fan-outs to all detected adapters; `detach` round-trips cleanly and preserves the user's other MCP/context-server entries. Marker SSoT is `~/.claude/keisei-attached.toml` (schema v2 — list of attachments; v1 auto-migrated). | ## Primitives (shell) diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 1b33bd9..8fb1b62 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -1144,6 +1144,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "keisei" +version = "0.1.0" +dependencies = [ + "clap", + "rusqlite", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror 2.0.18", + "toml", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1981,7 +1995,7 @@ dependencies = [ "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-stream", "tracing", @@ -2065,7 +2079,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "whoami", ] @@ -2103,7 +2117,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "whoami", ] @@ -2211,7 +2225,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -2225,6 +2248,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/_primitives/_rust/keisei/Cargo.toml b/_primitives/_rust/keisei/Cargo.toml index 472117b..165221b 100644 --- a/_primitives/_rust/keisei/Cargo.toml +++ b/_primitives/_rust/keisei/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" toml = "0.8" thiserror = "2" rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/_primitives/_rust/keisei/src/adapter.rs b/_primitives/_rust/keisei/src/adapter.rs index 661d80b..b87a085 100644 --- a/_primitives/_rust/keisei/src/adapter.rs +++ b/_primitives/_rust/keisei/src/adapter.rs @@ -1,14 +1,17 @@ //! Adapter trait + registry — the pluggable surface for AI clients. //! -//! Constructor Pattern: this file owns the trait + the "pick an adapter -//! that detects itself on this host" function. Each concrete adapter +//! Constructor Pattern: this file owns the trait + the "enumerate all +//! adapters" function + lookup-by-name helper. Each concrete adapter //! lives in its own file under `adapters/`. //! -//! MVP: only `ClaudeCodeAdapter`. Future clients (Cursor, Continue, -//! Aider, etc.) each become one new file under `adapters/` and one new -//! line in `all()`. +//! v0.19: Claude Code + Cursor + Continue + Zed. -use crate::adapters::claude_code::ClaudeCodeAdapter; +use crate::adapters::{ + claude_code::ClaudeCodeAdapter, + continue_adapter::ContinueAdapter, + cursor::CursorAdapter, + zed::ZedAdapter, +}; use crate::brain::Brain; use crate::error::{Error, Result}; use std::path::PathBuf; @@ -22,8 +25,14 @@ pub trait ClientAdapter { } /// Enumerate all adapters the binary knows about, in priority order. +/// Order matters: `detect_active()` returns the first positive hit. pub fn all() -> Vec> { - vec![Box::new(ClaudeCodeAdapter::new())] + vec![ + Box::new(ClaudeCodeAdapter::new()), + Box::new(CursorAdapter::new()), + Box::new(ContinueAdapter::new()), + Box::new(ZedAdapter::new()), + ] } /// Return the first adapter whose `detect()` fires. `NoClientDetected` @@ -36,3 +45,9 @@ pub fn detect_active() -> Result> { } Err(Error::NoClientDetected) } + +/// Look up an adapter by its `name()`. Used by the detach flow which +/// iterates client names from the saved marker. +pub fn by_name(name: &str) -> Option> { + all().into_iter().find(|a| a.name() == name) +} diff --git a/_primitives/_rust/keisei/src/adapters/claude_code.rs b/_primitives/_rust/keisei/src/adapters/claude_code.rs index 4d12c0c..bc08638 100644 --- a/_primitives/_rust/keisei/src/adapters/claude_code.rs +++ b/_primitives/_rust/keisei/src/adapters/claude_code.rs @@ -1,23 +1,20 @@ //! Claude Code adapter — writes MCP server entry into -//! `~/.claude/settings.json` (or project-local `.claude/settings.json`). +//! `~/.claude/settings.json`. Config shape merges under +//! `mcpServers.keisei` so we never clobber unrelated entries. //! -//! Constructor Pattern: single responsibility — own the Claude-Code -//! specific attach/detach ritual. Config shape merges into existing JSON -//! under `mcpServers.` so we never clobber unrelated entries. -//! -//! Detection: Claude Code is "present" if either -//! - `$CWD/.claude/settings.json` exists, OR -//! - `$KEISEI_HOME/.claude` (or `$HOME/.claude`) exists as a directory. -//! -//! Testability: `$KEISEI_HOME` overrides `$HOME` so integration tests can -//! point at a tmpdir without touching the real user config. +//! Detection: `$CWD/.claude/settings.json` exists OR +//! `$KEISEI_HOME/.claude` (or `$HOME/.claude`) is a directory. +//! `$KEISEI_HOME` overrides `$HOME` for tests. use crate::adapter::ClientAdapter; 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 struct ClaudeCodeAdapter; impl ClaudeCodeAdapter { @@ -58,27 +55,19 @@ impl ClientAdapter for ClaudeCodeAdapter { if let Some(parent) = cfg.parent() { std::fs::create_dir_all(parent)?; } - let mut doc: Value = if cfg.is_file() { - let raw = std::fs::read_to_string(&cfg)?; - if raw.trim().is_empty() { - json!({}) - } else { - serde_json::from_str(&raw)? - } - } else { - json!({}) - }; + let mut doc = load_json_or_empty(&cfg)?; merge_mcp_entry(&mut doc, brain); - let pretty = serde_json::to_string_pretty(&doc)?; - write_atomic(&cfg, &pretty)?; - Ok(()) + write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?) } fn detach(&self) -> Result<()> { - // MVP: not implemented. Phase 1 delivers attach + status only. - // A later pass will strip the brain's entry from mcpServers and - // delete the keisei-attached.toml marker. - Ok(()) + let cfg = self.config_path(); + if !cfg.is_file() { + return Ok(()); + } + let mut doc = load_json_or_empty(&cfg)?; + remove_mcp_entry(&mut doc); + write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?) } fn config_path(&self) -> PathBuf { @@ -86,8 +75,17 @@ impl ClientAdapter for ClaudeCodeAdapter { } } -/// Merge the brain's MCP server entry under `mcpServers.`. -/// Existing keys in the top-level doc and in `mcpServers` are preserved. +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!({}); @@ -103,19 +101,27 @@ fn merge_mcp_entry(doc: &mut Value, brain: &Brain) { "command": brain.mcp_server_path().to_string_lossy(), "args": [], "env": { - "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy() + "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(), + "KEISEI_BRAIN_NAME": brain.name(), } }); servers .as_object_mut() .expect("servers is object") - .insert(brain.name().to_string(), entry); + .insert(MCP_ENTRY_KEY.to_string(), entry); } -/// Write-then-rename to avoid truncating the settings file on crash. -fn write_atomic(target: &std::path::Path, content: &str) -> Result<()> { - let tmp = target.with_extension("json.tmp"); - std::fs::write(&tmp, content)?; - std::fs::rename(&tmp, target)?; - Ok(()) +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 new file mode 100644 index 0000000..31805c4 --- /dev/null +++ b/_primitives/_rust/keisei/src/adapters/continue_adapter.rs @@ -0,0 +1,179 @@ +//! 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`: +//! ```yaml +//! mcpServers: +//! - name: keisei +//! command: /path/to/kei-mcp-server +//! 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; +use crate::error::{Error, Result}; +use crate::fsx::write_atomic; +use serde_json::{json, Value}; +use std::path::PathBuf; + +pub const SERVER_NAME: &str = "keisei"; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Form { + Yaml, + Json, +} + +pub struct ContinueAdapter; + +impl ContinueAdapter { + pub fn new() -> Self { + Self + } + + fn continue_dir(&self) -> PathBuf { + let base = std::env::var("KEISEI_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| std::env::var("HOME").ok().map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")); + base.join(".continue") + } + + fn pick_form_and_path(&self) -> (Form, PathBuf) { + let dir = self.continue_dir(); + let yaml = dir.join("config.yaml"); + let json = dir.join("config.json"); + if yaml.is_file() { + (Form::Yaml, yaml) + } else if json.is_file() { + (Form::Json, json) + } else { + // Default for a fresh install: YAML (Continue's preferred form). + (Form::Yaml, yaml) + } + } +} + +impl Default for ContinueAdapter { + fn default() -> Self { + Self::new() + } +} + +impl ClientAdapter for ContinueAdapter { + fn name(&self) -> &str { + "continue" + } + + fn detect(&self) -> bool { + self.continue_dir().is_dir() + } + + fn attach(&self, brain: &Brain) -> Result<()> { + let (form, cfg) = self.pick_form_and_path(); + if let Some(parent) = cfg.parent() { + std::fs::create_dir_all(parent)?; + } + let mut doc = load_doc(&cfg, form)?; + merge_entry(&mut doc, brain); + write_doc(&cfg, form, &doc) + } + + fn detach(&self) -> Result<()> { + let (form, cfg) = self.pick_form_and_path(); + if !cfg.is_file() { + return Ok(()); + } + let mut doc = load_doc(&cfg, form)?; + remove_entry(&mut doc); + write_doc(&cfg, form, &doc) + } + + fn config_path(&self) -> PathBuf { + self.pick_form_and_path().1 + } +} + +/// 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!({})); + } + let raw = std::fs::read_to_string(cfg)?; + if raw.trim().is_empty() { + return Ok(json!({})); + } + let parsed: Value = 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) +} + +fn write_doc(cfg: &std::path::Path, form: Form, doc: &Value) -> Result<()> { + let text = match form { + Form::Yaml => serde_yaml::to_string(doc)?, + Form::Json => serde_json::to_string_pretty(doc)?, + }; + write_atomic(cfg, &text) +} + +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 entry = json!({ + "name": SERVER_NAME, + "command": brain.mcp_server_path().to_string_lossy(), + "args": [], + "env": { + "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(), + "KEISEI_BRAIN_NAME": brain.name(), + } + }); + let servers = obj + .entry("mcpServers".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !servers.is_array() { + *servers = Value::Array(Vec::new()); + } + let arr = servers.as_array_mut().expect("array after guard"); + arr.retain(|v| v.get("name").and_then(|n| n.as_str()) != Some(SERVER_NAME)); + arr.push(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(arr) = servers.as_array_mut() { + arr.retain(|v| v.get("name").and_then(|n| n.as_str()) != Some(SERVER_NAME)); + if arr.is_empty() { + obj.remove("mcpServers"); + } + } + } +} diff --git a/_primitives/_rust/keisei/src/adapters/cursor.rs b/_primitives/_rust/keisei/src/adapters/cursor.rs new file mode 100644 index 0000000..29ffcaf --- /dev/null +++ b/_primitives/_rust/keisei/src/adapters/cursor.rs @@ -0,0 +1,140 @@ +//! Cursor adapter — writes MCP server entry to Cursor's MCP config. +//! +//! 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]: +//! `{ "mcpServers": { "keisei": { "command": "...", "args": [] } } }`. + +use crate::adapter::ClientAdapter; +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 struct CursorAdapter; + +impl CursorAdapter { + pub fn new() -> Self { + Self + } + + fn home_cursor_dir(&self) -> PathBuf { + let base = std::env::var("KEISEI_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| std::env::var("HOME").ok().map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")); + base.join(".cursor") + } + + fn project_cursor_dir(&self) -> Option { + std::env::current_dir().ok().map(|p| p.join(".cursor")) + } + + fn pick_config_path(&self) -> PathBuf { + if let Some(proj) = self.project_cursor_dir() { + if proj.is_dir() { + return proj.join("mcp.json"); + } + } + self.home_cursor_dir().join("mcp.json") + } +} + +impl Default for CursorAdapter { + fn default() -> Self { + Self::new() + } +} + +impl ClientAdapter for CursorAdapter { + fn name(&self) -> &str { + "cursor" + } + + fn detect(&self) -> bool { + let proj = self + .project_cursor_dir() + .map(|p| p.is_dir()) + .unwrap_or(false); + proj || self.home_cursor_dir().is_dir() + } + + fn attach(&self, brain: &Brain) -> Result<()> { + let cfg = self.config_path(); + 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)?) + } + + fn detach(&self) -> Result<()> { + let cfg = self.config_path(); + 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)?) + } + + fn config_path(&self) -> PathBuf { + 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/mod.rs b/_primitives/_rust/keisei/src/adapters/mod.rs index 2e0a2fb..8bdf00f 100644 --- a/_primitives/_rust/keisei/src/adapters/mod.rs +++ b/_primitives/_rust/keisei/src/adapters/mod.rs @@ -4,3 +4,6 @@ //! no logic lives here. Each adapter owns its own file. pub mod claude_code; +pub mod continue_adapter; +pub mod cursor; +pub mod zed; diff --git a/_primitives/_rust/keisei/src/adapters/zed.rs b/_primitives/_rust/keisei/src/adapters/zed.rs new file mode 100644 index 0000000..8a16db5 --- /dev/null +++ b/_primitives/_rust/keisei/src/adapters/zed.rs @@ -0,0 +1,159 @@ +//! Zed adapter — writes MCP/context-server entry into Zed settings. +//! +//! 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. + +use crate::adapter::ClientAdapter; +use crate::brain::Brain; +use crate::error::{Error, Result}; +use crate::fsx::write_atomic; +use serde_json::{json, Map, Value}; +use std::path::PathBuf; + +pub const ENTRY_KEY: &str = "keisei"; + +pub struct ZedAdapter; + +impl ZedAdapter { + pub fn new() -> Self { + Self + } + + fn settings_dir(&self) -> PathBuf { + let base = std::env::var("KEISEI_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| std::env::var("HOME").ok().map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")); + if cfg!(target_os = "macos") { + base.join("Library/Application Support/Zed") + } else { + base.join(".config/zed") + } + } + + fn settings_file(&self) -> PathBuf { + self.settings_dir().join("settings.json") + } +} + +impl Default for ZedAdapter { + fn default() -> Self { + Self::new() + } +} + +impl ClientAdapter for ZedAdapter { + fn name(&self) -> &str { + "zed" + } + + fn detect(&self) -> bool { + self.settings_dir().is_dir() + } + + fn attach(&self, brain: &Brain) -> Result<()> { + let cfg = self.config_path(); + 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(()) + } + + fn detach(&self) -> Result<()> { + let cfg = self.config_path(); + 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(()) + } + + fn config_path(&self) -> PathBuf { + self.settings_file() + } +} + +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!({})); + } + serde_json::from_str(&raw).map_err(|e| Error::ConfigParseError { + path: cfg.to_path_buf(), + 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/attach.rs b/_primitives/_rust/keisei/src/attach.rs index 9f2e975..7ffe02a 100644 --- a/_primitives/_rust/keisei/src/attach.rs +++ b/_primitives/_rust/keisei/src/attach.rs @@ -2,13 +2,13 @@ //! //! Constructor Pattern: single responsibility — orchestrate the 7-step //! attach ritual (canonicalize → load manifest → validate schema → -//! detect client → adapter.attach → write SSoT marker → print summary). +//! detect client → adapter.attach → write SSoT marker v2 → print summary). //! No I/O here beyond what the `brain`, `adapter`, and `config` modules //! already own. use crate::adapter::{detect_active, ClientAdapter}; use crate::brain::Brain; -use crate::config::{self, AttachRecord}; +use crate::config::{self, AttachRecord, Attachment}; use crate::error::Result; use std::path::Path; @@ -26,18 +26,18 @@ fn build_record(brain: &Brain, adapter: &dyn ClientAdapter) -> AttachRecord { AttachRecord { brain_path: brain.root.to_string_lossy().into_owned(), brain_name: brain.name().to_string(), - client_type: adapter.name().to_string(), attached_at: config::now_utc_string(), + attachments: vec![Attachment { + client_type: adapter.name().to_string(), + config_path: adapter.config_path().to_string_lossy().into_owned(), + }], } } fn print_summary(brain: &Brain, adapter: &dyn ClientAdapter, marker: &std::path::Path) { println!("attached brain '{}' to {}", brain.name(), adapter.name()); println!(" brain path: {}", brain.root.display()); - println!( - " mcp server: {}", - brain.mcp_server_path().display() - ); + println!(" mcp server: {}", brain.mcp_server_path().display()); println!(" client cfg: {}", adapter.config_path().display()); println!(" marker: {}", marker.display()); println!("run /help in Claude Code to verify the MCP server is reachable"); diff --git a/_primitives/_rust/keisei/src/config.rs b/_primitives/_rust/keisei/src/config.rs index 4c4e01e..de96126 100644 --- a/_primitives/_rust/keisei/src/config.rs +++ b/_primitives/_rust/keisei/src/config.rs @@ -1,18 +1,34 @@ //! SSoT for the active attach: `~/.claude/keisei-attached.toml`. //! -//! File shape: +//! Schema v2 (v0.19, multi-client): +//! ```toml +//! brain_path = "/Volumes/Brain1" +//! brain_name = "my-ai-brain" +//! attached_at = "2026-04-22T14:23:00Z" +//! +//! [[attachments]] +//! client_type = "claude-code" +//! config_path = "/Users/me/.claude/settings.json" +//! +//! [[attachments]] +//! client_type = "cursor" +//! config_path = "/Users/me/proj/.cursor/mcp.json" +//! ``` +//! +//! Schema v1 (v0.18, single-client, still readable): //! ```toml //! brain_path = "/Volumes/Brain1" //! brain_name = "my-ai-brain" //! client_type = "claude-code" //! attached_at = "2026-04-22T14:23:00Z" //! ``` +//! On first access we parse either shape into the v2 `AttachRecord`. //! //! Constructor Pattern: single responsibility — read/write the attach //! marker. No knowledge of Brain schema or adapter behaviour. //! -//! Testability: `$KEISEI_HOME` overrides `$HOME` (same convention as -//! `ClaudeCodeAdapter`) so integration tests isolate state per tmpdir. +//! Testability: `$KEISEI_HOME` overrides `$HOME` so integration tests +//! isolate state per tmpdir. use crate::error::Result; use serde::{Deserialize, Serialize}; @@ -20,12 +36,66 @@ use std::path::PathBuf; pub const ATTACHED_FILENAME: &str = "keisei-attached.toml"; +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct Attachment { + pub client_type: String, + pub config_path: String, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AttachRecord { pub brain_path: String, pub brain_name: String, - pub client_type: String, pub attached_at: String, + #[serde(default)] + pub attachments: Vec, +} + +impl AttachRecord { + /// Convenience: are we attached to the given client? + pub fn has_client(&self, client: &str) -> bool { + self.attachments.iter().any(|a| a.client_type == client) + } + + /// List of attached client names in declaration order. + pub fn client_names(&self) -> Vec { + self.attachments + .iter() + .map(|a| a.client_type.clone()) + .collect() + } +} + +/// Raw wire shape: accepts BOTH v1 (flat `client_type`) and v2 (list). +#[derive(Debug, Deserialize)] +struct WireRecord { + brain_path: String, + brain_name: String, + attached_at: String, + #[serde(default)] + client_type: Option, + #[serde(default)] + attachments: Vec, +} + +impl WireRecord { + fn into_v2(self) -> AttachRecord { + let attachments = if !self.attachments.is_empty() { + self.attachments + } else if let Some(ct) = self.client_type { + // v1 → v2 migration. config_path unknown at migration time; + // leave blank — adapter will re-derive from `config_path()`. + vec![Attachment { client_type: ct, config_path: String::new() }] + } else { + Vec::new() + }; + AttachRecord { + brain_path: self.brain_path, + brain_name: self.brain_name, + attached_at: self.attached_at, + attachments, + } + } } pub fn home_root() -> PathBuf { @@ -57,8 +127,17 @@ pub fn read() -> Result> { return Ok(None); } let raw = std::fs::read_to_string(&path)?; - let rec: AttachRecord = toml::from_str(&raw)?; - Ok(Some(rec)) + let wire: WireRecord = toml::from_str(&raw)?; + Ok(Some(wire.into_v2())) +} + +pub fn delete() -> Result { + let path = attached_path(); + if !path.is_file() { + return Ok(false); + } + std::fs::remove_file(&path)?; + Ok(true) } /// RFC-3339-ish UTC timestamp. Avoids a `chrono` dep for this one field — diff --git a/_primitives/_rust/keisei/src/detach.rs b/_primitives/_rust/keisei/src/detach.rs new file mode 100644 index 0000000..892debe --- /dev/null +++ b/_primitives/_rust/keisei/src/detach.rs @@ -0,0 +1,75 @@ +//! `keisei detach` implementation. +//! +//! Constructor Pattern: single responsibility — read the v2 marker, +//! iterate recorded client attachments, call `adapter.detach()` on each, +//! delete the marker file after all adapters succeed. Per-adapter +//! failures are collected and reported but do NOT abort the other +//! detaches — partial detach is better than stuck state. + +use crate::adapter::{self, ClientAdapter}; +use crate::config::{self, AttachRecord}; +use crate::error::Result; + +pub fn run() -> Result<()> { + let Some(rec) = config::read()? else { + println!("no brain attached; nothing to detach"); + return Ok(()); + }; + + let (succeeded, failed) = detach_all(&rec); + print_summary(&rec, &succeeded, &failed); + + if failed.is_empty() { + config::delete()?; + } else { + eprintln!( + "keisei: {} adapter(s) failed to detach cleanly — marker retained", + failed.len() + ); + } + Ok(()) +} + +/// For each attachment in the marker, run `adapter.detach()`. +/// Returns `(succeeded_names, failed_pairs)`. +fn detach_all(rec: &AttachRecord) -> (Vec, Vec<(String, String)>) { + let mut ok = Vec::new(); + let mut err = Vec::new(); + let names = resolve_client_names(rec); + for name in names { + match adapter::by_name(&name) { + Some(a) => match a.detach() { + Ok(()) => ok.push(a.name().to_string()), + Err(e) => err.push((name, e.to_string())), + }, + None => err.push((name, "unknown adapter (not registered)".to_string())), + } + } + (ok, err) +} + +/// v2 attachments carry the full list; a v1 marker that migrated with an +/// empty client_type fallback falls through to every registered adapter +/// (best-effort — safer than leaking config entries behind). +fn resolve_client_names(rec: &AttachRecord) -> Vec { + if rec.attachments.is_empty() { + return adapter::all() + .iter() + .map(|a| a.name().to_string()) + .collect(); + } + rec.attachments + .iter() + .map(|a| a.client_type.clone()) + .collect() +} + +fn print_summary(rec: &AttachRecord, ok: &[String], err: &[(String, String)]) { + if !ok.is_empty() { + println!("detached from: {}", ok.join(", ")); + } + for (client, reason) in err { + eprintln!(" ! {}: {}", client, reason); + } + println!("brain was: {}", rec.brain_path); +} diff --git a/_primitives/_rust/keisei/src/error.rs b/_primitives/_rust/keisei/src/error.rs index 05cf753..071b6c5 100644 --- a/_primitives/_rust/keisei/src/error.rs +++ b/_primitives/_rust/keisei/src/error.rs @@ -1,8 +1,8 @@ //! Error type for the `keisei` CLI. //! //! Constructor Pattern: single responsibility — own all failure modes of the -//! attach / status flow as one thiserror enum. Every other module returns -//! `Result` using the `#[from]` conversions declared here. +//! attach / status / mount / detach flow as one thiserror enum. Every other +//! module returns `Result` using the `#[from]` conversions here. use std::path::PathBuf; @@ -22,6 +22,12 @@ pub enum Error { #[error("no brain currently attached")] NotAttached, + #[error("adapter '{client}' failed: {reason}")] + AdapterFailed { client: String, reason: String }, + + #[error("config parse error at {path}: {reason}")] + ConfigParseError { path: PathBuf, reason: String }, + #[error("i/o error: {0}")] Io(#[from] std::io::Error), @@ -33,4 +39,7 @@ pub enum Error { #[error("json: {0}")] Json(#[from] serde_json::Error), + + #[error("yaml: {0}")] + Yaml(#[from] serde_yaml::Error), } diff --git a/_primitives/_rust/keisei/src/fsx.rs b/_primitives/_rust/keisei/src/fsx.rs new file mode 100644 index 0000000..ec27757 --- /dev/null +++ b/_primitives/_rust/keisei/src/fsx.rs @@ -0,0 +1,30 @@ +//! Filesystem helpers shared across adapters. +//! +//! Constructor Pattern: single responsibility — own the write-then-rename +//! pattern. Kept in a dedicated module so every adapter shares the exact +//! same crash-safe write, regardless of extension. + +use crate::error::Result; +use std::ffi::OsString; +use std::path::Path; + +/// Write-then-rename to avoid truncating the target on crash. +/// +/// The temp file lives in the same directory as the target (required for +/// a POSIX-atomic rename). Its name is `.tmp` — no randomness +/// since only one writer touches an adapter's config at a time. +pub fn write_atomic(target: &Path, content: &str) -> Result<()> { + let tmp = tmp_path(target); + std::fs::write(&tmp, content)?; + std::fs::rename(&tmp, target)?; + Ok(()) +} + +fn tmp_path(target: &Path) -> std::path::PathBuf { + let mut name: OsString = target.file_name().map(OsString::from).unwrap_or_default(); + name.push(".tmp"); + match target.parent() { + Some(p) if !p.as_os_str().is_empty() => p.join(name), + _ => std::path::PathBuf::from(name), + } +} diff --git a/_primitives/_rust/keisei/src/list.rs b/_primitives/_rust/keisei/src/list.rs new file mode 100644 index 0000000..de52a86 --- /dev/null +++ b/_primitives/_rust/keisei/src/list.rs @@ -0,0 +1,55 @@ +//! `keisei list-adapters` — read-only dump of every registered adapter +//! and its detection state on this host. +//! +//! Constructor Pattern: single responsibility — render a tabular view. +//! No state mutation, no config touches. + +use crate::adapter; +use crate::error::Result; + +pub fn run() -> Result<()> { + let rows: Vec = adapter::all() + .iter() + .map(|a| Row { + name: a.name().to_string(), + detected: a.detect(), + config_path: a.config_path().to_string_lossy().into_owned(), + }) + .collect(); + print_table(&rows); + Ok(()) +} + +struct Row { + name: String, + detected: bool, + config_path: String, +} + +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()) +} diff --git a/_primitives/_rust/keisei/src/main.rs b/_primitives/_rust/keisei/src/main.rs index 9777ae8..ab3aacc 100644 --- a/_primitives/_rust/keisei/src/main.rs +++ b/_primitives/_rust/keisei/src/main.rs @@ -1,14 +1,19 @@ -//! keisei — exobrain attach/status CLI (MVP, Claude Code only). +//! keisei — exobrain attach/status CLI (v0.19 multi-client). //! //! Constructor Pattern: main.rs = clap parse + dispatch only. All -//! subcommand logic lives in sibling modules (`attach.rs`, `status.rs`). +//! subcommand logic lives in sibling modules +//! (`attach.rs`, `status.rs`, `mount.rs`, `detach.rs`, `list.rs`). mod adapter; mod adapters; mod attach; mod brain; mod config; +mod detach; mod error; +mod fsx; +mod list; +mod mount; mod status; use clap::{Parser, Subcommand}; @@ -19,7 +24,7 @@ use std::process::ExitCode; #[command( name = "keisei", version, - about = "Exobrain attach/status — mount a portable brain into an AI client" + about = "Exobrain CLI — mount a portable brain into any supported AI client" )] struct Cli { #[command(subcommand)] @@ -28,20 +33,32 @@ struct Cli { #[derive(Subcommand)] enum Cmd { - /// Attach a brain directory to the currently detected AI client. + /// Attach a brain to the single currently detected AI client. Attach { /// Path to the brain directory (must contain manifest.toml). brain_path: PathBuf, }, + /// Attach a brain to EVERY detected AI client in one shot. + Mount { + /// Path to the brain directory (must contain manifest.toml). + brain_path: PathBuf, + }, + /// Remove the brain from every client recorded in the marker. + Detach, /// Show the currently attached brain + health checks. Status, + /// List every registered adapter + whether it's detected here. + ListAdapters, } fn main() -> ExitCode { let cli = Cli::parse(); let res = match cli.cmd { Cmd::Attach { brain_path } => attach::run(&brain_path), + Cmd::Mount { brain_path } => mount::run(&brain_path), + Cmd::Detach => detach::run(), Cmd::Status => status::run(), + Cmd::ListAdapters => list::run(), }; match res { Ok(()) => ExitCode::SUCCESS, diff --git a/_primitives/_rust/keisei/src/mount.rs b/_primitives/_rust/keisei/src/mount.rs new file mode 100644 index 0000000..f65a018 --- /dev/null +++ b/_primitives/_rust/keisei/src/mount.rs @@ -0,0 +1,93 @@ +//! `keisei mount ` — attach to every detected client. +//! +//! Constructor Pattern: single responsibility — orchestrate the fan-out +//! (load brain → enumerate adapters → attach each one whose `detect()` +//! fires → collect successes/failures → write v2 marker with the +//! successful list → print summary). No config-schema knowledge beyond +//! what the `config` module already owns. + +use crate::adapter::{self, ClientAdapter}; +use crate::brain::Brain; +use crate::config::{self, AttachRecord, Attachment}; +use crate::error::{Error, Result}; +use std::path::Path; + +pub fn run(brain_path: &Path) -> Result<()> { + let brain = Brain::load(brain_path)?; + let (succeeded, failed) = mount_all(&brain); + if succeeded.is_empty() { + print_all_failed(&failed); + return Err(Error::NoClientDetected); + } + let rec = build_record(&brain, &succeeded); + let marker = config::write(&rec)?; + print_summary(&brain, &succeeded, &failed, &marker); + Ok(()) +} + +struct Success { + client_type: String, + config_path: String, +} + +/// Returns `(succeeded, failed)` where: +/// - succeeded: adapters that detected AND attached OK +/// - failed: adapters that detected BUT attach() errored +/// Adapters that didn't detect aren't reported either way. +fn mount_all(brain: &Brain) -> (Vec, Vec<(String, String)>) { + let mut ok = Vec::new(); + let mut err = Vec::new(); + for a in adapter::all() { + if !a.detect() { + continue; + } + match a.attach(brain) { + Ok(()) => ok.push(Success { + client_type: a.name().to_string(), + config_path: a.config_path().to_string_lossy().into_owned(), + }), + Err(e) => err.push((a.name().to_string(), e.to_string())), + } + } + (ok, err) +} + +fn build_record(brain: &Brain, succeeded: &[Success]) -> AttachRecord { + AttachRecord { + brain_path: brain.root.to_string_lossy().into_owned(), + brain_name: brain.name().to_string(), + attached_at: config::now_utc_string(), + attachments: succeeded + .iter() + .map(|s| Attachment { + client_type: s.client_type.clone(), + config_path: s.config_path.clone(), + }) + .collect(), + } +} + +fn print_all_failed(failed: &[(String, String)]) { + eprintln!("keisei: no MCP-capable client detected on this host"); + for (client, reason) in failed { + eprintln!(" ! {}: {}", client, reason); + } + eprintln!("install Claude Code, Cursor, Continue, or Zed, then retry."); +} + +fn print_summary( + brain: &Brain, + ok: &[Success], + err: &[(String, String)], + marker: &std::path::Path, +) { + println!("mounted brain '{}' to:", brain.name()); + for s in ok { + println!(" [OK] {}: {}", s.client_type, s.config_path); + } + for (client, reason) in err { + eprintln!(" [FAIL] {}: {}", client, reason); + } + println!("marker: {}", marker.display()); + println!("run `keisei status` to inspect, `keisei detach` to remove."); +} diff --git a/_primitives/_rust/keisei/src/status.rs b/_primitives/_rust/keisei/src/status.rs index b8d0108..c8eeb88 100644 --- a/_primitives/_rust/keisei/src/status.rs +++ b/_primitives/_rust/keisei/src/status.rs @@ -1,8 +1,8 @@ //! `keisei status` implementation. //! -//! Constructor Pattern: single responsibility — read the `keisei-attached.toml` -//! SSoT, verify brain + mcp binary still exist, print a human-readable -//! summary with an `[OK]` / `[WARN]` health line. +//! Constructor Pattern: single responsibility — read the +//! `keisei-attached.toml` SSoT (v1 or v2), verify brain + mcp binary +//! still exist, print a human-readable summary with per-client health. use crate::brain::Brain; use crate::config::{self, AttachRecord}; @@ -13,7 +13,7 @@ pub fn run() -> Result<()> { match config::read()? { None => { println!("no brain attached"); - println!("run: keisei attach "); + println!("run: keisei attach or keisei mount "); Ok(()) } Some(rec) => { @@ -27,8 +27,20 @@ pub fn run() -> Result<()> { fn print_record(rec: &AttachRecord) { println!("brain: {}", rec.brain_name); println!("brain path: {}", rec.brain_path); - println!("client: {}", rec.client_type); println!("attached at: {}", rec.attached_at); + if rec.attachments.is_empty() { + println!("clients: (none — marker migrated from v1 without client info)"); + } else { + println!("clients: {}", rec.client_names().join(", ")); + for a in &rec.attachments { + let cfg = if a.config_path.is_empty() { + "(unknown — v1 marker)".to_string() + } else { + a.config_path.clone() + }; + println!(" - {}: {}", a.client_type, cfg); + } + } } fn print_health(rec: &AttachRecord) { diff --git a/_primitives/_rust/keisei/tests/integration.rs b/_primitives/_rust/keisei/tests/integration.rs index dfb5a70..96077e5 100644 --- a/_primitives/_rust/keisei/tests/integration.rs +++ b/_primitives/_rust/keisei/tests/integration.rs @@ -12,6 +12,8 @@ mod error; mod brain; #[path = "../src/config.rs"] mod config; +#[path = "../src/fsx.rs"] +mod fsx; #[path = "../src/adapters/mod.rs"] mod adapters; #[path = "../src/adapter.rs"] @@ -20,7 +22,14 @@ mod adapter; mod attach; #[path = "../src/status.rs"] mod status; +#[path = "../src/mount.rs"] +mod mount; +#[path = "../src/detach.rs"] +mod detach; +#[path = "../src/list.rs"] +mod list; +use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -45,6 +54,15 @@ fn setup_home() -> EnvGuard { EnvGuard { _lock: lock, _home: home } } +/// Variant of `setup_home` that does NOT pre-create the `.claude` dir. +/// Used by tests that want to verify "no client detected" failure paths. +fn setup_home_bare() -> EnvGuard { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let home = tempfile::tempdir().unwrap(); + std::env::set_var("KEISEI_HOME", home.path()); + EnvGuard { _lock: lock, _home: home } +} + fn write_brain(root: &Path, schema: u32) -> PathBuf { fs::create_dir_all(root.join("bin")).unwrap(); fs::write(root.join("bin/kei-mcp-server-test"), b"#!/bin/sh\n").unwrap(); @@ -76,7 +94,7 @@ fn attach_then_status_happy_path() { // Marker file exists with correct fields. let rec = config::read().unwrap().expect("record present"); assert_eq!(rec.brain_name, "test-brain"); - assert_eq!(rec.client_type, "claude-code"); + assert!(rec.has_client("claude-code"), "claude-code should be in attachments"); assert!(rec.attached_at.ends_with('Z')); // Status runs without error when attached. @@ -127,7 +145,12 @@ fn attach_writes_marker_with_expected_fields() { // brain_path stored as canonicalized absolute path. assert!(Path::new(&rec.brain_path).is_absolute()); assert_eq!(rec.brain_name, "test-brain"); - assert_eq!(rec.client_type, "claude-code"); + assert_eq!(rec.attachments.len(), 1); + assert_eq!(rec.attachments[0].client_type, "claude-code"); + assert!( + !rec.attachments[0].config_path.is_empty(), + "config_path should be populated on v2 write" + ); // Marker file itself lives under $KEISEI_HOME/.claude/. let marker = config::attached_path(); @@ -138,5 +161,170 @@ fn attach_writes_marker_with_expected_fields() { assert!(settings.is_file(), "settings.json not written"); let text = fs::read_to_string(&settings).unwrap(); assert!(text.contains("mcpServers"), "mcpServers key missing"); - assert!(text.contains("test-brain"), "brain-name key missing"); + assert!(text.contains("keisei"), "keisei mcp entry missing"); +} + +// ----------------------------------------------------------------------- +// New v0.19 tests (multi-client). +// ----------------------------------------------------------------------- + +#[test] +fn mount_with_claude_code_only_detected() { + let _g = setup_home(); + // Only .claude/ exists (setup_home creates it). No .cursor, .continue, + // no Zed dirs. Mount should detect exactly one client. + let brain_dir = tempfile::tempdir().unwrap(); + write_brain(brain_dir.path(), 1); + + mount::run(brain_dir.path()).expect("mount ok"); + + let rec = config::read().unwrap().expect("record present"); + assert_eq!( + rec.attachments.len(), + 1, + "only claude-code should be attached, got {:?}", + rec.client_names() + ); + assert_eq!(rec.attachments[0].client_type, "claude-code"); +} + +#[test] +fn mount_with_no_client_detected() { + let _g = setup_home_bare(); + // Bare home — no .claude, no .cursor, no .continue, no Zed dirs. + let brain_dir = tempfile::tempdir().unwrap(); + write_brain(brain_dir.path(), 1); + + let err = mount::run(brain_dir.path()).unwrap_err(); + assert!( + matches!(err, error::Error::NoClientDetected), + "got {err:?}" + ); + // Marker must NOT be written on failure. + assert!(config::read().unwrap().is_none()); +} + +#[test] +fn detach_round_trip() { + let _g = setup_home(); + let brain_dir = tempfile::tempdir().unwrap(); + write_brain(brain_dir.path(), 1); + + attach::run(brain_dir.path()).expect("attach ok"); + let settings = config::attached_path().parent().unwrap().join("settings.json"); + assert!(settings.is_file()); + // Sanity: keisei entry is present BEFORE detach. + let before: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert!( + before + .get("mcpServers") + .and_then(|s| s.get("keisei")) + .is_some(), + "keisei entry missing pre-detach: {before}" + ); + + detach::run().expect("detach ok"); + + // Marker gone. + assert!( + config::read().unwrap().is_none(), + "marker not deleted after detach" + ); + // settings.json still exists; keisei entry stripped. + assert!(settings.is_file()); + let after: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + let has_keisei = after + .get("mcpServers") + .and_then(|s| s.get("keisei")) + .is_some(); + assert!(!has_keisei, "keisei entry survived detach: {after}"); +} + +#[test] +fn detach_preserves_other_mcp_servers() { + let _g = setup_home(); + let settings = config::home_root().join("settings.json"); + // Pre-populate with a user's pre-existing MCP server. + fs::write( + &settings, + r#"{ + "mcpServers": { + "other": { "command": "/usr/local/bin/other-mcp", "args": [] } + }, + "userPref": 42 +}"#, + ) + .unwrap(); + + let brain_dir = tempfile::tempdir().unwrap(); + write_brain(brain_dir.path(), 1); + attach::run(brain_dir.path()).expect("attach ok"); + detach::run().expect("detach ok"); + + let after: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + // `other` mcp server survives. + assert!( + after + .get("mcpServers") + .and_then(|s| s.get("other")) + .is_some(), + "pre-existing 'other' server lost: {after}" + ); + // keisei is gone. + assert!( + after + .get("mcpServers") + .and_then(|s| s.get("keisei")) + .is_none(), + "keisei entry survived detach: {after}" + ); + // Unrelated top-level key preserved. + assert_eq!(after.get("userPref").and_then(|v| v.as_i64()), Some(42)); +} + +#[test] +fn list_adapters_prints_expected_rows() { + // list just enumerates adapter::all() — no home needed, but we lock + // the env to keep `detect()` reads deterministic. + let _g = setup_home(); + // Sanity check: all four adapter names are registered. + let names: Vec = adapter::all().iter().map(|a| a.name().to_string()).collect(); + assert!(names.contains(&"claude-code".to_string())); + assert!(names.contains(&"cursor".to_string())); + assert!(names.contains(&"continue".to_string())); + assert!(names.contains(&"zed".to_string())); + // Command itself runs without error. + list::run().expect("list-adapters ok"); +} + +#[test] +fn schema_v1_to_v2_migration() { + let _g = setup_home(); + // Hand-write a v1 marker. + let marker = config::attached_path(); + if let Some(parent) = marker.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write( + &marker, + r#"brain_path = "/tmp/brain-v1" +brain_name = "old-brain" +client_type = "claude-code" +attached_at = "2026-04-22T00:00:00Z" +"#, + ) + .unwrap(); + + let rec = config::read().unwrap().expect("v1 marker should parse"); + assert_eq!(rec.brain_name, "old-brain"); + assert_eq!(rec.brain_path, "/tmp/brain-v1"); + assert_eq!( + rec.attachments.len(), + 1, + "v1 client_type should migrate to single attachment" + ); + assert_eq!(rec.attachments[0].client_type, "claude-code"); + // v1 didn't carry config_path; migration leaves it blank. + assert_eq!(rec.attachments[0].config_path, ""); + assert!(rec.has_client("claude-code")); }