feat(v0.20): Brain schema v2 per-platform mcp_server + post_attach_hint() trait

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<String, String>)
  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) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-22 17:19:58 +08:00
parent 51715f2045
commit 12e56d6590
13 changed files with 341 additions and 77 deletions

View file

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

View file

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

View file

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

View file

@ -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<Value> {
@ -87,15 +91,16 @@ fn load_json_or_empty(cfg: &std::path::Path) -> Result<Value> {
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<Value> {
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 {

View file

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

View file

@ -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<Value> {
@ -100,15 +104,16 @@ fn load_json_or_empty(cfg: &std::path::Path) -> Result<Value> {
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<Value> {
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 {

View file

@ -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<Value> {
@ -115,15 +119,16 @@ fn load_json_or_empty(cfg: &std::path::Path) -> Result<Value> {
})
}
fn build_entry(brain: &Brain) -> Value {
json!({
"command": brain.mcp_server_path().to_string_lossy(),
fn build_entry(brain: &Brain) -> Result<Value> {
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 {

View file

@ -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());
}

View file

@ -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-<os>-<arch>"`
//! (one brain per platform).
//! * **v2** — `[paths.mcp_server]` table keyed by `<os>-<arch>` 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<String>,
}
/// v1 carries a single relative path; v2 carries a map keyed by
/// `<os>-<arch>`. 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<String, String>),
}
#[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<String>,
@ -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<Self> {
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 `<os>-<arch>` 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<PathBuf> {
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)?;
}

View file

@ -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<BrainManifest> {
}
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(())
}

View file

@ -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<String>,
},
#[error("no supported client detected in this directory")]
NoClientDetected,

View file

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

View file

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