Merge feat/v0.19-multi-client-adapters — pre-hardening integration

This commit is contained in:
Parfii-bot 2026-04-22 16:04:56 +08:00
commit d39abf1914
20 changed files with 1179 additions and 77 deletions

View file

@ -22,6 +22,13 @@ _primitives/_rust/target/release/kei-changelog \
### Added
- **primitives:** `keisei` CLI MVP — `attach <brain-path>` + `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 <brain-path>` — 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`).

View file

@ -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)

View file

@ -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"

View file

@ -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"] }

View file

@ -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<Box<dyn ClientAdapter>> {
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<Box<dyn ClientAdapter>> {
}
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<Box<dyn ClientAdapter>> {
all().into_iter().find(|a| a.name() == name)
}

View file

@ -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.<brain-name>` 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.<brain-name>`.
/// Existing keys in the top-level doc and in `mcpServers` are preserved.
fn load_json_or_empty(cfg: &std::path::Path) -> Result<Value> {
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");
}
}
}
}

View file

@ -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<Value> {
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");
}
}
}
}

View file

@ -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<PathBuf> {
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<Value> {
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");
}
}
}
}

View file

@ -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;

View file

@ -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<Value> {
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");
}
}
}
}

View file

@ -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");

View file

@ -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<Attachment>,
}
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<String> {
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<String>,
#[serde(default)]
attachments: Vec<Attachment>,
}
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<Option<AttachRecord>> {
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<bool> {
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 —

View file

@ -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<String>, 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<String> {
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);
}

View file

@ -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<T, Error>` using the `#[from]` conversions declared here.
//! attach / status / mount / detach flow as one thiserror enum. Every other
//! module returns `Result<T, Error>` 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),
}

View file

@ -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 `<target>.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),
}
}

View file

@ -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<Row> = 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!(
"{:<name_w$} {:<det_w$} config_path",
"adapter",
"detected",
name_w = name_w,
det_w = det_w
);
println!("{:<name_w$} {:<det_w$} {}", "-------", "--------", "-----------", name_w = name_w, det_w = det_w);
for r in rows {
let mark = if r.detected { "yes" } else { "no" };
println!(
"{:<name_w$} {:<det_w$} {}",
r.name,
mark,
r.config_path,
name_w = name_w,
det_w = det_w
);
}
}
fn header_or_max<F: Fn(&Row) -> usize>(rows: &[Row], header: &str, f: F) -> usize {
rows.iter().map(f).max().unwrap_or(0).max(header.len())
}

View file

@ -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,

View file

@ -0,0 +1,93 @@
//! `keisei mount <brain-path>` — 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<Success>, 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.");
}

View file

@ -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 <brain-path>");
println!("run: keisei attach <brain-path> or keisei mount <brain-path>");
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) {

View file

@ -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<String> = 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"));
}