From 12e56d659077c0e2b7c1d8340133a49804e91f83 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Wed, 22 Apr 2026 17:19:58 +0800 Subject: [PATCH] feat(v0.20): Brain schema v2 per-platform mcp_server + post_attach_hint() trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 2 architect audit P3 findings. MVP on the USB-droppable brain vision — one brain directory now serves every platform. Schema v2 — per-platform mcp_server dispatch: [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' Schema v1 (single string) still accepted — v0.19 brains load unchanged. Implementation: brain.rs — new McpServerPath enum (Single / PerPlatform BTreeMap) with #[serde(untagged)]. Brain::current_platform_key() maps std::env::consts (macos→darwin, x86_64→x64, aarch64→arm64) to canonical key format. mcp_server_path() now returns Result — looks up current platform, returns Error::NoPlatformBinary { os, arch, available } if missing. Pre-canonicalized cache field removed so partial v2 brains load for status (just fail at actual resolve). brain_validate.rs — validate_schema accepts MIN..=MAX range (1 or 2); check_all_paths iterates v2 map entries for confinement check. ClientAdapter::post_attach_hint() — default method + 4 overrides: claude_code: 'run /help in Claude Code to verify the MCP server is reachable' cursor: 'reload Cursor window (Cmd+Shift+P → Reload Window) to pick up the MCP server' continue_adapter: 'reload the Continue extension in VS Code (or restart) to pick up the MCP server' zed: 'run Zed :reload command to pick up the MCP server config' attach.rs prints adapter.post_attach_hint() instead of the hardcoded Claude-Code-specific string. No more client leak in orchestrator. Error::NoPlatformBinary { os, arch, available } with thiserror Display. Tests: 16 existing + 4 new = 20/20 pass. - schema_v2_current_platform_resolves - schema_v2_missing_current_platform_errors (macOS-gated) - schema_v1_still_readable_with_v2_code - post_attach_hint_is_adapter_specific Constructor Pattern: all files <200 LOC (continue_adapter.rs 197 LOC max). All fns <30 LOC (current_platform_key + check_all_paths 19 LOC max). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 + README.md | 22 ++- _primitives/_rust/keisei/src/adapter.rs | 10 ++ .../_rust/keisei/src/adapters/claude_code.rs | 15 +- .../keisei/src/adapters/continue_adapter.rs | 27 ++-- .../_rust/keisei/src/adapters/cursor.rs | 15 +- _primitives/_rust/keisei/src/adapters/zed.rs | 15 +- _primitives/_rust/keisei/src/attach.rs | 7 +- _primitives/_rust/keisei/src/brain.rs | 138 ++++++++++++----- .../_rust/keisei/src/brain_validate.rs | 9 +- _primitives/_rust/keisei/src/error.rs | 12 +- _primitives/_rust/keisei/src/status.rs | 2 +- _primitives/_rust/keisei/tests/integration.rs | 143 ++++++++++++++++++ 13 files changed, 341 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 896e50f..d2c2ab3 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 7ffe02a..c8909a1 100644 --- a/_primitives/_rust/keisei/src/attach.rs +++ b/_primitives/_rust/keisei/src/attach.rs @@ -37,8 +37,11 @@ fn build_record(brain: &Brain, adapter: &dyn ClientAdapter) -> AttachRecord { 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()); + match brain.mcp_server_path() { + Ok(p) => println!(" mcp server: {}", p.display()), + Err(e) => println!(" mcp server: [unresolved — {}]", e), + } 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 a2756a5..69cdd87 100644 --- a/_primitives/_rust/keisei/src/brain.rs +++ b/_primitives/_rust/keisei/src/brain.rs @@ -1,38 +1,41 @@ //! 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. //! -//! Every path under `[paths]` MUST be relative AND resolve (after +//! Platform key format: 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. +//! +//! Every declared path MUST be relative AND resolve (after //! canonicalization) inside the brain root. Absolute paths or `..` -//! traversal are rejected with `Error::PathEscape`. Symlink roots are -//! rejected with `Error::BrainIsSymlink` (user must pass the canonical -//! path explicitly to avoid USB→host pivot). +//! traversal are rejected with [`Error::PathEscape`]. Symlink roots are +//! rejected with [`Error::BrainIsSymlink`]. //! -//! Constructor Pattern: single responsibility — parse + compose the five +//! 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)] @@ -43,10 +46,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, @@ -68,9 +83,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 { @@ -79,9 +91,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)?; @@ -89,17 +104,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 { @@ -108,7 +161,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 818f568..567c3bd 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}; @@ -54,10 +54,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 acdacaa..d217fac 100644 --- a/_primitives/_rust/keisei/src/error.rs +++ b/_primitives/_rust/keisei/src/error.rs @@ -13,9 +13,19 @@ pub enum Error { #[error("brain manifest not found at {0}")] BrainNotFound(PathBuf), - #[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 c8eeb88..16bd231 100644 --- a/_primitives/_rust/keisei/src/status.rs +++ b/_primitives/_rust/keisei/src/status.rs @@ -60,7 +60,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 479461c..b4c7d90 100644 --- a/_primitives/_rust/keisei/tests/integration.rs +++ b/_primitives/_rust/keisei/tests/integration.rs @@ -482,3 +482,146 @@ attached_at = "2026-04-22T00:00:00Z" assert_eq!(rec.attachments[0].config_path, ""); assert!(rec.has_client("claude-code")); } + +// ----------------------------------------------------------------------- +// 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 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}" + ); +}