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:
parent
51715f2045
commit
12e56d6590
13 changed files with 341 additions and 77 deletions
|
|
@ -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).
|
||||
|
|
|
|||
22
README.md
22
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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue