diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e92f25..2c73303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ _primitives/_rust/target/release/kei-changelog \ > ships must be replaced with the real commit summary before release. ### Added +- **primitives (v0.20 — brain schema v2 + per-client hint):** + - Brain schema v2 with per-platform `mcp_server` dispatch — a single brain directory can now host binaries for darwin-arm64/darwin-x64/linux-x64/linux-arm64/windows-x64 and `keisei attach` picks the right one automatically. Schema v1 (single string) still accepted for backward-compat. + - `ClientAdapter::post_attach_hint()` — per-client reload instruction, no more hardcoded Claude-Code string in the orchestrator. - **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). diff --git a/README.md b/README.md index 7f84148..2d79344 100644 --- a/README.md +++ b/README.md @@ -179,10 +179,30 @@ The `keisei` Rust crate is the entry-point that turns a **brain directory** (por ├── manifests/ # user persona TOML library └── bin/ ├── kei-mcp-server-darwin-arm64 + ├── kei-mcp-server-darwin-x64 ├── kei-mcp-server-linux-x64 - └── ... # per-platform binaries + ├── kei-mcp-server-linux-arm64 + └── kei-mcp-server-windows-x64.exe ``` +**`manifest.toml` — schema v2 (recommended, v0.20+)** dispatches to the right binary for the host at attach time: + +```toml +[brain] +schema_version = 2 +name = "my-brain" +created = "2026-04-22T00:00:00Z" + +[paths.mcp_server] +darwin-arm64 = "bin/kei-mcp-server-darwin-arm64" +darwin-x64 = "bin/kei-mcp-server-darwin-x64" +linux-x64 = "bin/kei-mcp-server-linux-x64" +linux-arm64 = "bin/kei-mcp-server-linux-arm64" +windows-x64 = "bin/kei-mcp-server-windows-x64.exe" +``` + +A single brain on USB/iCloud now serves every host automatically. Schema v1 (single-string `mcp_server = "bin/..."`) is still accepted for backward-compat. + Four CLI commands: | Command | What it does | diff --git a/_primitives/_rust/keisei/src/adapter.rs b/_primitives/_rust/keisei/src/adapter.rs index b87a085..18c48f7 100644 --- a/_primitives/_rust/keisei/src/adapter.rs +++ b/_primitives/_rust/keisei/src/adapter.rs @@ -22,6 +22,16 @@ pub trait ClientAdapter { fn attach(&self, brain: &Brain) -> Result<()>; fn detach(&self) -> Result<()>; fn config_path(&self) -> PathBuf; + + /// One-line instruction the CLI prints after a successful attach so + /// the user knows how to make the client see the new MCP server. + /// Adapters override this with a client-specific phrasing (reload + /// command, command palette entry, etc). Default is a generic + /// fallback that keeps the orchestrator free of client-specific + /// strings. + fn post_attach_hint(&self) -> &str { + "reload your AI client to pick up the new MCP server" + } } /// Enumerate all adapters the binary knows about, in priority order. diff --git a/_primitives/_rust/keisei/src/adapters/claude_code.rs b/_primitives/_rust/keisei/src/adapters/claude_code.rs index 840ed37..a00d2f1 100644 --- a/_primitives/_rust/keisei/src/adapters/claude_code.rs +++ b/_primitives/_rust/keisei/src/adapters/claude_code.rs @@ -74,6 +74,10 @@ impl ClientAdapter for ClaudeCodeAdapter { fn config_path(&self) -> PathBuf { self.user_config_dir().join("settings.json") } + + fn post_attach_hint(&self) -> &str { + "run /help in Claude Code to verify the MCP server is reachable" + } } fn load_json_or_empty(cfg: &std::path::Path) -> Result { @@ -87,15 +91,16 @@ fn load_json_or_empty(cfg: &std::path::Path) -> Result { Ok(serde_json::from_str(&raw)?) } -fn build_entry(brain: &Brain) -> Value { - json!({ - "command": brain.mcp_server_path().to_string_lossy(), +fn build_entry(brain: &Brain) -> Result { + let mcp = brain.mcp_server_path()?; + Ok(json!({ + "command": mcp.to_string_lossy(), "args": [], "env": { "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(), "KEISEI_BRAIN_NAME": brain.name(), } - }) + })) } fn merge_mcp_entry(doc: &mut Value, brain: &Brain) -> Result<()> { @@ -109,7 +114,7 @@ fn merge_mcp_entry(doc: &mut Value, brain: &Brain) -> Result<()> { if !servers.is_object() { *servers = Value::Object(Map::new()); } - let entry = build_entry(brain); + let entry = build_entry(brain)?; let map = servers.as_object_mut().expect("servers is object"); if let Some(existing) = map.get(MCP_ENTRY_KEY) { if existing != &entry { diff --git a/_primitives/_rust/keisei/src/adapters/continue_adapter.rs b/_primitives/_rust/keisei/src/adapters/continue_adapter.rs index 44cf9e7..2f93a24 100644 --- a/_primitives/_rust/keisei/src/adapters/continue_adapter.rs +++ b/_primitives/_rust/keisei/src/adapters/continue_adapter.rs @@ -17,15 +17,11 @@ //! ``` //! //! 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. +//! session. Adapter uses list-form `mcpServers` from v0.18 prototypes + +//! public Continue `config.yaml` docs. Detach preserves unrelated keys. //! -//! Detach preserves all other config keys and all non-keisei servers. -//! -//! Security (v0.19 audit): if an existing `name: keisei` entry has -//! different content than we'd write, attach fails with `NameConflict` -//! instead of silent overwrite. +//! Security (v0.19 audit): collision-safe — existing `name: keisei` with +//! different content → `NameConflict`, no silent overwrite. use crate::adapter::ClientAdapter; use crate::brain::Brain; @@ -108,6 +104,10 @@ impl ClientAdapter for ContinueAdapter { fn config_path(&self) -> PathBuf { self.pick_form_and_path().1 } + + fn post_attach_hint(&self) -> &str { + "reload the Continue extension in VS Code (or restart) to pick up the MCP server" + } } /// Load doc as a generic `serde_json::Value`. YAML → Value via serde_yaml, @@ -139,16 +139,17 @@ fn write_doc(cfg: &std::path::Path, form: Form, doc: &Value) -> Result<()> { write_atomic(cfg, &text) } -fn build_entry(brain: &Brain) -> Value { - json!({ +fn build_entry(brain: &Brain) -> Result { + let mcp = brain.mcp_server_path()?; + Ok(json!({ "name": SERVER_NAME, - "command": brain.mcp_server_path().to_string_lossy(), + "command": mcp.to_string_lossy(), "args": [], "env": { "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(), "KEISEI_BRAIN_NAME": brain.name(), } - }) + })) } fn merge_entry(doc: &mut Value, brain: &Brain) -> Result<()> { @@ -156,7 +157,7 @@ fn merge_entry(doc: &mut Value, brain: &Brain) -> Result<()> { *doc = json!({}); } let obj = doc.as_object_mut().expect("doc is object after guard"); - let entry = build_entry(brain); + let entry = build_entry(brain)?; let servers = obj .entry("mcpServers".to_string()) .or_insert_with(|| Value::Array(Vec::new())); diff --git a/_primitives/_rust/keisei/src/adapters/cursor.rs b/_primitives/_rust/keisei/src/adapters/cursor.rs index cda8b5f..bb18b87 100644 --- a/_primitives/_rust/keisei/src/adapters/cursor.rs +++ b/_primitives/_rust/keisei/src/adapters/cursor.rs @@ -87,6 +87,10 @@ impl ClientAdapter for CursorAdapter { fn config_path(&self) -> PathBuf { self.pick_config_path() } + + fn post_attach_hint(&self) -> &str { + "reload Cursor window (Cmd+Shift+P → 'Reload Window') to pick up the MCP server" + } } fn load_json_or_empty(cfg: &std::path::Path) -> Result { @@ -100,15 +104,16 @@ fn load_json_or_empty(cfg: &std::path::Path) -> Result { Ok(serde_json::from_str(&raw)?) } -fn build_entry(brain: &Brain) -> Value { - json!({ - "command": brain.mcp_server_path().to_string_lossy(), +fn build_entry(brain: &Brain) -> Result { + let mcp = brain.mcp_server_path()?; + Ok(json!({ + "command": mcp.to_string_lossy(), "args": [], "env": { "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(), "KEISEI_BRAIN_NAME": brain.name(), } - }) + })) } fn merge_entry(doc: &mut Value, brain: &Brain) -> Result<()> { @@ -122,7 +127,7 @@ fn merge_entry(doc: &mut Value, brain: &Brain) -> Result<()> { if !servers.is_object() { *servers = Value::Object(Map::new()); } - let entry = build_entry(brain); + let entry = build_entry(brain)?; let map = servers.as_object_mut().expect("servers is object"); if let Some(existing) = map.get(MCP_ENTRY_KEY) { if existing != &entry { diff --git a/_primitives/_rust/keisei/src/adapters/zed.rs b/_primitives/_rust/keisei/src/adapters/zed.rs index e0942a7..0e2296b 100644 --- a/_primitives/_rust/keisei/src/adapters/zed.rs +++ b/_primitives/_rust/keisei/src/adapters/zed.rs @@ -99,6 +99,10 @@ impl ClientAdapter for ZedAdapter { fn config_path(&self) -> PathBuf { self.settings_file() } + + fn post_attach_hint(&self) -> &str { + "run Zed's :reload command to pick up the MCP server config" + } } fn load_json_or_empty(cfg: &std::path::Path) -> Result { @@ -115,15 +119,16 @@ fn load_json_or_empty(cfg: &std::path::Path) -> Result { }) } -fn build_entry(brain: &Brain) -> Value { - json!({ - "command": brain.mcp_server_path().to_string_lossy(), +fn build_entry(brain: &Brain) -> Result { + let mcp = brain.mcp_server_path()?; + Ok(json!({ + "command": mcp.to_string_lossy(), "args": [], "env": { "KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(), "KEISEI_BRAIN_NAME": brain.name(), } - }) + })) } fn merge_entry(doc: &mut Value, brain: &Brain) -> Result<()> { @@ -137,7 +142,7 @@ fn merge_entry(doc: &mut Value, brain: &Brain) -> Result<()> { if !servers.is_object() { *servers = Value::Object(Map::new()); } - let entry = build_entry(brain); + let entry = build_entry(brain)?; let map = servers.as_object_mut().expect("servers is object"); if let Some(existing) = map.get(ENTRY_KEY) { if existing != &entry { diff --git a/_primitives/_rust/keisei/src/attach.rs b/_primitives/_rust/keisei/src/attach.rs index 90432fc..2daf672 100644 --- a/_primitives/_rust/keisei/src/attach.rs +++ b/_primitives/_rust/keisei/src/attach.rs @@ -38,11 +38,16 @@ fn build_record(brain: &Brain, adapter: &dyn ClientAdapter) -> AttachRecord { fn print_summary(brain: &Brain, adapter: &dyn ClientAdapter, marker: &std::path::Path) { let brain_name = sanitize_display(brain.name()); let brain_path = sanitize_display(&brain.root.to_string_lossy()); - let mcp_path = sanitize_display(&brain.mcp_server_path().to_string_lossy()); println!("attached brain '{}' to {}", brain_name, adapter.name()); println!(" brain path: {}", brain_path); - println!(" mcp server: {}", mcp_path); + match brain.mcp_server_path() { + Ok(p) => { + let mcp_path = sanitize_display(&p.to_string_lossy()); + println!(" mcp server: {}", mcp_path); + } + Err(e) => println!(" mcp server: [unresolved — {}]", sanitize_display(&e.to_string())), + } println!(" client cfg: {}", adapter.config_path().display()); println!(" marker: {}", marker.display()); - println!("run /help in Claude Code to verify the MCP server is reachable"); + println!("{}", adapter.post_attach_hint()); } diff --git a/_primitives/_rust/keisei/src/brain.rs b/_primitives/_rust/keisei/src/brain.rs index 5e82daa..7a3fbe6 100644 --- a/_primitives/_rust/keisei/src/brain.rs +++ b/_primitives/_rust/keisei/src/brain.rs @@ -1,29 +1,23 @@ //! Brain — portable exobrain directory representation. //! -//! A "brain" is a self-contained directory on any filesystem (flashdrive, -//! iCloud, external SSD, remote mount) that any supported AI client can -//! `attach` to via the `keisei` CLI. It declares its layout in a top-level -//! `manifest.toml` of the following shape: +//! A "brain" is a self-contained directory on any filesystem (USB, iCloud, +//! remote mount) attached to an AI client via the `keisei` CLI. It +//! declares its layout in a top-level `manifest.toml`. //! -//! ```toml -//! [brain] -//! schema_version = 1 -//! name = "my-ai-brain" # ^[a-z][a-z0-9_-]{0,63}$ -//! created = "2026-04-22T00:00:00Z" +//! Two schemas are supported: //! -//! [paths] -//! mcp_server = "bin/kei-mcp-server-darwin-arm64" # REQUIRED, relative, in-root -//! memory = "memory/" # optional -//! artifacts = "artifacts/" # optional -//! manifests = "manifests/" # optional -//! ``` +//! * **v1** — single-string `mcp_server = "bin/kei-mcp-server--"` +//! (one brain per platform). +//! * **v2** — `[paths.mcp_server]` table keyed by `-` so a +//! single brain on USB serves every host automatically. //! -//! # v0.19 invariants (audit-hardened) +//! # Invariants (audit-hardened, v0.19 + v0.20) //! //! - **Path confinement** — every path under `[paths]` MUST be relative; //! absolute paths and `..` components are rejected syntactically, and //! the canonical form must remain inside the brain root -//! (`Error::PathEscape`). +//! (`Error::PathEscape`). In schema v2 every map value is checked +//! independently. //! - **Symlink reject** — the brain-root input itself cannot be a //! symlink; the user must pass the canonical path to close the //! USB → `$HOME` pivot (`Error::BrainIsSymlink`). @@ -33,19 +27,30 @@ //! - **Manifest size bound** — `manifest.toml` is capped at 64 KiB //! (`brain_validate::MAX_MANIFEST_BYTES`); anything larger returns //! `Error::ManifestTooLarge` before the toml parser sees a byte. -//! - **Schema** — only `schema_version = 1` is accepted today. v2 -//! (multi-platform `mcp_server` per `{os, arch}`) is planned for v0.20 -//! and will be read side-by-side once landed. +//! - **Schema range** — `schema_version ∈ {1, 2}` accepted (see +//! `MIN_SCHEMA..=MAX_SCHEMA`). v1 = single-string `mcp_server`; v2 = +//! `[paths.mcp_server]` map keyed by `-`. //! -//! Constructor Pattern: single responsibility — parse + compose the five +//! Platform key format (v2): derived from `std::env::consts` with renames +//! `macos → darwin`, `x86_64 → x64`, `aarch64 → arm64`. See +//! [`Brain::current_platform_key`]. A brain may ship only a subset of +//! platforms — the missing ones surface as [`Error::NoPlatformBinary`] +//! at `mcp_server_path()` call time, NOT at load time, so +//! `keisei status` can still inspect a partial brain. +//! +//! Constructor Pattern: single responsibility — parse + compose the //! validation primitives from `brain_validate.rs` into the load pipeline. use crate::brain_validate as v; -use crate::error::Result; +use crate::error::{Error, Result}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -pub const SUPPORTED_SCHEMA: u32 = 1; +/// Lowest schema version understood by this binary. +pub const MIN_SCHEMA: u32 = 1; +/// Highest schema version understood by this binary. +pub const MAX_SCHEMA: u32 = 2; pub const MANIFEST_FILENAME: &str = "manifest.toml"; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -56,10 +61,22 @@ pub struct BrainMeta { pub created: Option, } +/// v1 carries a single relative path; v2 carries a map keyed by +/// `-`. Serde's `untagged` dispatch picks the right arm by TOML +/// shape — string vs table. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum McpServerPath { + /// Schema v1 form: single relative path good for one platform only. + Single(String), + /// Schema v2 form: `{ "darwin-arm64": "bin/...", "linux-x64": "bin/..." }`. + PerPlatform(BTreeMap), +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct BrainPaths { - /// Required. Path to the MCP server binary, relative to the brain root. - pub mcp_server: String, + /// Required. Path(s) to the MCP server binary, relative to the brain root. + pub mcp_server: McpServerPath, /// Optional. If present, must be relative + in-root. #[serde(default)] pub memory: Option, @@ -81,9 +98,6 @@ pub struct BrainManifest { pub struct Brain { pub root: PathBuf, pub manifest: BrainManifest, - /// Canonical absolute path to the mcp_server binary, pre-validated - /// to live under `root`. - canonical_mcp_server: PathBuf, } impl Brain { @@ -92,9 +106,12 @@ impl Brain { /// Order matters (security-critical): /// 1. Reject symlink-rooted inputs (SEC-H3 — USB/host pivot). /// 2. Canonicalize `root`. - /// 3. Parse manifest, validate schema_version. + /// 3. Parse manifest, validate schema_version ∈ {1, 2}. /// 4. Validate `brain.name` against regex. - /// 5. Validate + canonicalize every [paths] field; assert in-root. + /// 5. Syntactic path-escape check on every declared path (all v2 + /// platform entries included). Canonicalization is deferred to + /// `mcp_server_path()` so an incomplete brain (missing current + /// platform's binary) still loads and shows up in `status`. pub fn load(input: &Path) -> Result { v::reject_symlink_root(input)?; let root = v::canonicalize_root(input)?; @@ -102,17 +119,55 @@ impl Brain { v::validate_schema(&manifest)?; v::validate_name(&manifest.brain.name)?; check_all_paths(&manifest)?; - let canonical_mcp_server = v::canonicalize_in_root(&root, &manifest.paths.mcp_server)?; - Ok(Self { - root, - manifest, - canonical_mcp_server, - }) + Ok(Self { root, manifest }) } - /// Pre-validated, canonicalized absolute path to the mcp_server binary. - pub fn mcp_server_path(&self) -> PathBuf { - self.canonical_mcp_server.clone() + /// Return the `-` key used to look up v2 platform entries. + /// + /// Mapping (differs from raw `std::env::consts`): + /// * `macos` → `darwin` + /// * `x86_64` → `x64` + /// * `aarch64`→ `arm64` + /// * everything else passes through unchanged. + pub fn current_platform_key() -> String { + let os = match std::env::consts::OS { + "macos" => "darwin", + other => other, + }; + let arch = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + other => other, + }; + format!("{os}-{arch}") + } + + /// Resolve the mcp_server binary for the current host and canonicalize + /// against the brain root. Errors: + /// * [`Error::NoPlatformBinary`] — v2 brain without a map entry for + /// the current `(os, arch)`. + /// * [`Error::PathEscape`] / [`Error::BrainLoad`] / [`Error::BrainNotFound`] + /// — propagated from the canonicalizer. + pub fn mcp_server_path(&self) -> Result { + let rel = self.resolve_mcp_rel()?; + v::canonicalize_in_root(&self.root, rel) + } + + fn resolve_mcp_rel(&self) -> Result<&str> { + match &self.manifest.paths.mcp_server { + McpServerPath::Single(rel) => Ok(rel.as_str()), + McpServerPath::PerPlatform(map) => { + let key = Self::current_platform_key(); + match map.get(&key) { + Some(rel) => Ok(rel.as_str()), + None => Err(Error::NoPlatformBinary { + os: std::env::consts::OS.into(), + arch: std::env::consts::ARCH.into(), + available: map.keys().cloned().collect(), + }), + } + } + } } pub fn name(&self) -> &str { @@ -121,7 +176,14 @@ impl Brain { } fn check_all_paths(manifest: &BrainManifest) -> Result<()> { - v::check_relative_in_root(&manifest.paths.mcp_server)?; + match &manifest.paths.mcp_server { + McpServerPath::Single(rel) => v::check_relative_in_root(rel)?, + McpServerPath::PerPlatform(map) => { + for rel in map.values() { + v::check_relative_in_root(rel)?; + } + } + } if let Some(p) = manifest.paths.memory.as_deref() { v::check_relative_in_root(p)?; } diff --git a/_primitives/_rust/keisei/src/brain_validate.rs b/_primitives/_rust/keisei/src/brain_validate.rs index 8da4095..f021142 100644 --- a/_primitives/_rust/keisei/src/brain_validate.rs +++ b/_primitives/_rust/keisei/src/brain_validate.rs @@ -5,7 +5,7 @@ //! regex / in-root path guard). `brain.rs` composes them into the load //! pipeline. No cross-module state; every fn is pure w.r.t. filesystem. -use crate::brain::{BrainManifest, MANIFEST_FILENAME, SUPPORTED_SCHEMA}; +use crate::brain::{BrainManifest, MANIFEST_FILENAME, MAX_SCHEMA, MIN_SCHEMA}; use crate::error::{Error, Result}; use regex::Regex; use std::path::{Path, PathBuf}; @@ -66,10 +66,9 @@ pub fn read_manifest(root: &Path) -> Result { } pub fn validate_schema(manifest: &BrainManifest) -> Result<()> { - if manifest.brain.schema_version != SUPPORTED_SCHEMA { - return Err(Error::UnsupportedSchema { - found: manifest.brain.schema_version, - }); + let v = manifest.brain.schema_version; + if !(MIN_SCHEMA..=MAX_SCHEMA).contains(&v) { + return Err(Error::UnsupportedSchema { found: v }); } Ok(()) } diff --git a/_primitives/_rust/keisei/src/error.rs b/_primitives/_rust/keisei/src/error.rs index 90bd399..cf99eec 100644 --- a/_primitives/_rust/keisei/src/error.rs +++ b/_primitives/_rust/keisei/src/error.rs @@ -16,9 +16,19 @@ pub enum Error { #[error("manifest too large: {size} bytes (limit {max})")] ManifestTooLarge { size: u64, max: u64 }, - #[error("brain schema version {found} not supported (need 1)")] + #[error("brain schema version {found} not supported (need 1 or 2)")] UnsupportedSchema { found: u32 }, + #[error( + "no mcp_server binary for {os}-{arch}; available: {}", + available.join(", ") + )] + NoPlatformBinary { + os: String, + arch: String, + available: Vec, + }, + #[error("no supported client detected in this directory")] NoClientDetected, diff --git a/_primitives/_rust/keisei/src/status.rs b/_primitives/_rust/keisei/src/status.rs index 260361d..5b9a591 100644 --- a/_primitives/_rust/keisei/src/status.rs +++ b/_primitives/_rust/keisei/src/status.rs @@ -62,7 +62,7 @@ fn print_health(rec: &AttachRecord) { fn mcp_binary_ok(brain_root: &std::path::Path) -> bool { match Brain::load(brain_root) { - Ok(b) => b.mcp_server_path().is_file(), + Ok(b) => matches!(b.mcp_server_path(), Ok(p) if p.is_file()), Err(_) => false, } } diff --git a/_primitives/_rust/keisei/tests/integration.rs b/_primitives/_rust/keisei/tests/integration.rs index 9009fbf..495f1e2 100644 --- a/_primitives/_rust/keisei/tests/integration.rs +++ b/_primitives/_rust/keisei/tests/integration.rs @@ -550,6 +550,66 @@ mcp_server = "bin/kei-mcp-server-test" root.to_path_buf() } +// ----------------------------------------------------------------------- +// v0.20 schema-v2 + post_attach_hint tests. +// ----------------------------------------------------------------------- + +/// Write a schema-v2 brain manifest carrying every supported platform in +/// the `[paths.mcp_server]` table, plus a stub binary for the current +/// host so the canonicalizer is happy. +fn write_brain_v2_all_platforms(root: &Path) -> PathBuf { + fs::create_dir_all(root.join("bin")).unwrap(); + // Stub binaries for all five supported host tuples. We create them + // all so any host running the suite finds its own entry. + for name in &[ + "kei-mcp-server-darwin-arm64", + "kei-mcp-server-darwin-x64", + "kei-mcp-server-linux-x64", + "kei-mcp-server-linux-arm64", + "kei-mcp-server-windows-x64.exe", + ] { + fs::write(root.join("bin").join(name), b"#!/bin/sh\n").unwrap(); + } + let manifest = r#"[brain] +schema_version = 2 +name = "test-brain-v2" +created = "2026-04-22T00:00:00Z" + +[paths] +memory = "memory/" + +[paths.mcp_server] +darwin-arm64 = "bin/kei-mcp-server-darwin-arm64" +darwin-x64 = "bin/kei-mcp-server-darwin-x64" +linux-x64 = "bin/kei-mcp-server-linux-x64" +linux-arm64 = "bin/kei-mcp-server-linux-arm64" +windows-x64 = "bin/kei-mcp-server-windows-x64.exe" +"#; + fs::write(root.join("manifest.toml"), manifest).unwrap(); + root.to_path_buf() +} + +/// Write a v2 brain that only has `linux-x64` — used on macOS to exercise +/// the `NoPlatformBinary` error path. +fn write_brain_v2_linux_only(root: &Path) -> PathBuf { + fs::create_dir_all(root.join("bin")).unwrap(); + fs::write( + root.join("bin/kei-mcp-server-linux-x64"), + b"#!/bin/sh\n", + ) + .unwrap(); + let manifest = r#"[brain] +schema_version = 2 +name = "test-brain-linux" +created = "2026-04-22T00:00:00Z" + +[paths.mcp_server] +linux-x64 = "bin/kei-mcp-server-linux-x64" +"#; + fs::write(root.join("manifest.toml"), manifest).unwrap(); + root.to_path_buf() +} + #[test] fn manifest_too_large_rejected() { let _g = setup_home(); @@ -569,3 +629,86 @@ fn manifest_too_large_rejected() { // Containment: marker MUST NOT be written on rejection. assert!(config::read().unwrap().is_none()); } + +#[test] +fn schema_v2_current_platform_resolves() { + let _g = setup_home(); + let brain_dir = tempfile::tempdir().unwrap(); + write_brain_v2_all_platforms(brain_dir.path()); + + let brain = brain::Brain::load(brain_dir.path()).expect("v2 brain loads"); + let path = brain.mcp_server_path().expect("current platform resolves"); + assert!(path.is_file(), "resolved binary missing at {}", path.display()); + // Resolved path must live under the brain root. + let root = brain_dir.path().canonicalize().unwrap(); + assert!( + path.starts_with(&root), + "resolved path {} not under root {}", + path.display(), + root.display() + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn schema_v2_missing_current_platform_errors() { + let _g = setup_home(); + let brain_dir = tempfile::tempdir().unwrap(); + write_brain_v2_linux_only(brain_dir.path()); + + let brain = brain::Brain::load(brain_dir.path()) + .expect("v2 brain without current-platform binary still loads"); + let err = brain.mcp_server_path().unwrap_err(); + match err { + error::Error::NoPlatformBinary { ref available, .. } => { + assert_eq!(available, &vec!["linux-x64".to_string()]); + } + other => panic!("expected NoPlatformBinary, got {other:?}"), + } +} + +#[test] +fn schema_v1_still_readable_with_v2_code() { + let _g = setup_home(); + let brain_dir = tempfile::tempdir().unwrap(); + // `write_brain` emits schema_version = 1 + single-string mcp_server. + write_brain(brain_dir.path(), 1); + + let brain = brain::Brain::load(brain_dir.path()).expect("v1 brain still loads under v2 code"); + let path = brain.mcp_server_path().expect("v1 resolves without platform map"); + assert!(path.is_file(), "v1-resolved binary missing at {}", path.display()); +} + +#[test] +fn post_attach_hint_is_adapter_specific() { + let _g = setup_home(); + let adapters = adapter::all(); + let by_name = |n: &str| -> String { + adapters + .iter() + .find(|a| a.name() == n) + .unwrap_or_else(|| panic!("adapter {n} missing")) + .post_attach_hint() + .to_string() + }; + let claude = by_name("claude-code"); + let cursor = by_name("cursor"); + let cont = by_name("continue"); + let zed = by_name("zed"); + assert!( + claude.contains("/help"), + "claude-code hint lost /help marker: {claude}" + ); + assert!( + cursor.contains("Reload Window"), + "cursor hint lost 'Reload Window' marker: {cursor}" + ); + assert!( + cont.contains("Continue"), + "continue hint lost 'Continue' marker: {cont}" + ); + assert!( + zed.contains(":reload"), + "zed hint lost ':reload' marker: {zed}" + ); +}