Merge feat/v0.20-schema-v2-multi-platform — schema v2 multi-platform + post_attach_hint

Conflicts resolved by composition (not picking sides):
  error.rs: keep ManifestTooLarge (v0.19.2) + NoPlatformBinary (v0.20);
    UnsupportedSchema message updated to 'need 1 or 2'.
  brain.rs: merged v0.19.2 invariants block + v0.20 platform-key docs
    into a single Invariants section listing both hardening constraints
    and v2 schema range.
  attach.rs: composed v0.19.2 sanitize_display wrapping with v0.20
    Result<PathBuf> handling — mcp_server_path errors now sanitized too.
  integration.rs: concatenated v0.19.2 (3 tests + helper) + v0.20
    (4 tests + 2 helpers) blocks preserving all 7 new cases.

Tests: 23/23 pass (16 existing + 3 v0.19.2 + 4 v0.20).
cargo check -p keisei: clean.

v1 brains still load, v2 brains dispatch per-platform, adapters have
client-specific post_attach_hint.
This commit is contained in:
Parfii-bot 2026-04-22 17:23:18 +08:00
commit 909205f63b
13 changed files with 347 additions and 79 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

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

View file

@ -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-<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.
//!
//! # 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 `<os>-<arch>`.
//!
//! 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<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>,
@ -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<Self> {
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 `<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 {
@ -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)?;
}

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};
@ -66,10 +66,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

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

View file

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

View file

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