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:
commit
909205f63b
13 changed files with 347 additions and 79 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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue