Merge feat/v0.18-keisei-cli-mvp — exobrain attach/status CLI

This commit is contained in:
Parfii-bot 2026-04-22 15:52:59 +08:00
commit e53ad26243
15 changed files with 736 additions and 1 deletions

View file

@ -21,6 +21,7 @@ _primitives/_rust/target/release/kei-changelog \
> ships must be replaced with the real commit summary before release.
### Added
- **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).
- Placeholder: CHANGELOG.md generation wired through `kei-changelog` (this file).
- Placeholder: `.github/workflows/release.yml` — tag-driven multi-platform release.
- Placeholder: pre-built-binary install path in `install.sh` (`KEI_SKIP_RUST_BUILD=1`).

View file

@ -326,6 +326,7 @@ Requires the new `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`,
| `kei-refactor-engine` | v0.13.0 — consumes `kei-conflict-scan` JSON; emits plan markdown + auto-resolve review markdown (NOT a unified diff; v0.14.1 retraction) |
| `kei-graph-check` | v0.13.0 — post-refactor wikilink + handoff + block-ref resolver gate |
| `kei-store` | v0.13.0 — memory-repo backend abstraction (GitHub / Forgejo / Gitea / Filesystem / S3) |
| `keisei` | v0.18.0 — exobrain `attach` / `status` CLI (MVP: Claude Code) — mounts a portable brain into an AI client |
## Primitives (shell)

View file

@ -22,7 +22,7 @@ frontend = ["mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-
ops = ["kei-ledger", "ssh-check", "firewall-diff", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship"]
dev = ["kei-migrate", "kei-changelog", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-artifact"]
mcp = ["kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth"]
full = ["tomd", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth", "kei-artifact"]
full = ["tomd", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth", "kei-artifact", "keisei"]
# --- shell primitives (13) -------------------------------------------------
@ -253,3 +253,11 @@ kind = "rust"
crate = "kei-artifact"
deps = ["rusqlite bundled"]
desc = "Typed artifact handoff pipeline — schema-validated content pass-between agents (BMAD-style)"
# --- v0.18 exobrain CLI (1) ------------------------------------------------
[primitive.keisei]
kind = "rust"
crate = "keisei"
deps = ["rusqlite bundled (no system sqlite required)"]
desc = "Exobrain attach/status CLI — mounts a portable brain into an AI client (MVP: Claude Code)"

View file

@ -27,6 +27,8 @@ members = [
"kei-auth",
# v0.15 artifact handoff pipeline
"kei-artifact",
# v0.18 exobrain CLI
"keisei",
]
[workspace.package]

View file

@ -0,0 +1,21 @@
[package]
name = "keisei"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Exobrain attach/status CLI — mounts a portable brain (memory + manifests + artifacts + MCP bin) into an AI client (MVP: Claude Code)"
[[bin]]
name = "keisei"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
thiserror = "2"
rusqlite = { version = "0.31", features = ["bundled"] }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,38 @@
//! Adapter trait + registry — the pluggable surface for AI clients.
//!
//! Constructor Pattern: this file owns the trait + the "pick an adapter
//! that detects itself on this host" function. Each concrete adapter
//! lives in its own file under `adapters/`.
//!
//! MVP: only `ClaudeCodeAdapter`. Future clients (Cursor, Continue,
//! Aider, etc.) each become one new file under `adapters/` and one new
//! line in `all()`.
use crate::adapters::claude_code::ClaudeCodeAdapter;
use crate::brain::Brain;
use crate::error::{Error, Result};
use std::path::PathBuf;
pub trait ClientAdapter {
fn name(&self) -> &str;
fn detect(&self) -> bool;
fn attach(&self, brain: &Brain) -> Result<()>;
fn detach(&self) -> Result<()>;
fn config_path(&self) -> PathBuf;
}
/// Enumerate all adapters the binary knows about, in priority order.
pub fn all() -> Vec<Box<dyn ClientAdapter>> {
vec![Box::new(ClaudeCodeAdapter::new())]
}
/// Return the first adapter whose `detect()` fires. `NoClientDetected`
/// otherwise.
pub fn detect_active() -> Result<Box<dyn ClientAdapter>> {
for a in all() {
if a.detect() {
return Ok(a);
}
}
Err(Error::NoClientDetected)
}

View file

@ -0,0 +1,121 @@
//! Claude Code adapter — writes MCP server entry into
//! `~/.claude/settings.json` (or project-local `.claude/settings.json`).
//!
//! Constructor Pattern: single responsibility — own the Claude-Code
//! specific attach/detach ritual. Config shape merges into existing JSON
//! under `mcpServers.<brain-name>` so we never clobber unrelated entries.
//!
//! Detection: Claude Code is "present" if either
//! - `$CWD/.claude/settings.json` exists, OR
//! - `$KEISEI_HOME/.claude` (or `$HOME/.claude`) exists as a directory.
//!
//! Testability: `$KEISEI_HOME` overrides `$HOME` so integration tests can
//! point at a tmpdir without touching the real user config.
use crate::adapter::ClientAdapter;
use crate::brain::Brain;
use crate::error::Result;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
pub struct ClaudeCodeAdapter;
impl ClaudeCodeAdapter {
pub fn new() -> Self {
Self
}
fn user_config_dir(&self) -> PathBuf {
let base = std::env::var("KEISEI_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| std::env::var("HOME").ok().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("."));
base.join(".claude")
}
}
impl Default for ClaudeCodeAdapter {
fn default() -> Self {
Self::new()
}
}
impl ClientAdapter for ClaudeCodeAdapter {
fn name(&self) -> &str {
"claude-code"
}
fn detect(&self) -> bool {
let cwd_local = std::env::current_dir()
.map(|p| p.join(".claude/settings.json").is_file())
.unwrap_or(false);
cwd_local || self.user_config_dir().is_dir()
}
fn attach(&self, brain: &Brain) -> Result<()> {
let cfg = self.config_path();
if let Some(parent) = cfg.parent() {
std::fs::create_dir_all(parent)?;
}
let mut doc: Value = if cfg.is_file() {
let raw = std::fs::read_to_string(&cfg)?;
if raw.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&raw)?
}
} else {
json!({})
};
merge_mcp_entry(&mut doc, brain);
let pretty = serde_json::to_string_pretty(&doc)?;
write_atomic(&cfg, &pretty)?;
Ok(())
}
fn detach(&self) -> Result<()> {
// MVP: not implemented. Phase 1 delivers attach + status only.
// A later pass will strip the brain's entry from mcpServers and
// delete the keisei-attached.toml marker.
Ok(())
}
fn config_path(&self) -> PathBuf {
self.user_config_dir().join("settings.json")
}
}
/// Merge the brain's MCP server entry under `mcpServers.<brain-name>`.
/// Existing keys in the top-level doc and in `mcpServers` are preserved.
fn merge_mcp_entry(doc: &mut Value, brain: &Brain) {
if !doc.is_object() {
*doc = json!({});
}
let obj = doc.as_object_mut().expect("doc is object after guard");
let servers = obj
.entry("mcpServers".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !servers.is_object() {
*servers = Value::Object(Map::new());
}
let entry = json!({
"command": brain.mcp_server_path().to_string_lossy(),
"args": [],
"env": {
"KEISEI_BRAIN_ROOT": brain.root.to_string_lossy()
}
});
servers
.as_object_mut()
.expect("servers is object")
.insert(brain.name().to_string(), entry);
}
/// Write-then-rename to avoid truncating the settings file on crash.
fn write_atomic(target: &std::path::Path, content: &str) -> Result<()> {
let tmp = target.with_extension("json.tmp");
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, target)?;
Ok(())
}

View file

@ -0,0 +1,6 @@
//! Concrete `ClientAdapter` implementations, one file per client.
//!
//! Constructor Pattern: this file is the module declaration hub only —
//! no logic lives here. Each adapter owns its own file.
pub mod claude_code;

View file

@ -0,0 +1,44 @@
//! `keisei attach <brain-path>` implementation.
//!
//! Constructor Pattern: single responsibility — orchestrate the 7-step
//! attach ritual (canonicalize → load manifest → validate schema →
//! detect client → adapter.attach → write SSoT marker → print summary).
//! No I/O here beyond what the `brain`, `adapter`, and `config` modules
//! already own.
use crate::adapter::{detect_active, ClientAdapter};
use crate::brain::Brain;
use crate::config::{self, AttachRecord};
use crate::error::Result;
use std::path::Path;
pub fn run(brain_path: &Path) -> Result<()> {
let brain = Brain::load(brain_path)?;
let adapter = detect_active()?;
adapter.attach(&brain)?;
let rec = build_record(&brain, adapter.as_ref());
let marker = config::write(&rec)?;
print_summary(&brain, adapter.as_ref(), &marker);
Ok(())
}
fn build_record(brain: &Brain, adapter: &dyn ClientAdapter) -> AttachRecord {
AttachRecord {
brain_path: brain.root.to_string_lossy().into_owned(),
brain_name: brain.name().to_string(),
client_type: adapter.name().to_string(),
attached_at: config::now_utc_string(),
}
}
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()
);
println!(" client cfg: {}", adapter.config_path().display());
println!(" marker: {}", marker.display());
println!("run /help in Claude Code to verify the MCP server is reachable");
}

View file

@ -0,0 +1,103 @@
//! 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:
//!
//! ```toml
//! [brain]
//! schema_version = 1
//! name = "my-ai-brain"
//! created = "2026-04-22T00:00:00Z"
//!
//! [paths]
//! memory = "memory/"
//! artifacts = "artifacts/"
//! manifests = "manifests/"
//! mcp_server = "bin/kei-mcp-server-darwin-arm64"
//! ```
//!
//! Paths under `[paths]` are interpreted relative to the brain root. The
//! loader canonicalizes them on construction so downstream code can treat
//! them as absolute.
//!
//! Constructor Pattern: single responsibility — parse and validate the
//! brain manifest. No I/O beyond the initial read. No client coupling.
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const SUPPORTED_SCHEMA: u32 = 1;
pub const MANIFEST_FILENAME: &str = "manifest.toml";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BrainMeta {
pub schema_version: u32,
pub name: String,
#[serde(default)]
pub created: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BrainPaths {
pub memory: String,
pub artifacts: String,
pub manifests: String,
pub mcp_server: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BrainManifest {
pub brain: BrainMeta,
pub paths: BrainPaths,
}
#[derive(Debug, Clone)]
pub struct Brain {
pub root: PathBuf,
pub manifest: BrainManifest,
}
impl Brain {
/// Load a brain from `<root>/manifest.toml`.
/// Canonicalizes `root` to an absolute path and validates schema_version.
pub fn load(root: &Path) -> Result<Self> {
let root = root
.canonicalize()
.map_err(|_| Error::BrainNotFound(root.to_path_buf()))?;
let mpath = root.join(MANIFEST_FILENAME);
if !mpath.is_file() {
return Err(Error::BrainNotFound(mpath));
}
let raw = std::fs::read_to_string(&mpath)?;
let manifest: BrainManifest = toml::from_str(&raw)?;
if manifest.brain.schema_version != SUPPORTED_SCHEMA {
return Err(Error::UnsupportedSchema {
found: manifest.brain.schema_version,
});
}
Ok(Self { root, manifest })
}
/// Resolve a manifest-declared path (relative or absolute) to an
/// absolute path under the brain root. Trailing slashes are preserved
/// by `PathBuf` normalization.
pub fn resolve(&self, rel: &str) -> PathBuf {
let p = Path::new(rel);
if p.is_absolute() {
p.to_path_buf()
} else {
self.root.join(p)
}
}
pub fn mcp_server_path(&self) -> PathBuf {
self.resolve(&self.manifest.paths.mcp_server)
}
pub fn name(&self) -> &str {
&self.manifest.brain.name
}
}

View file

@ -0,0 +1,97 @@
//! SSoT for the active attach: `~/.claude/keisei-attached.toml`.
//!
//! File shape:
//! ```toml
//! brain_path = "/Volumes/Brain1"
//! brain_name = "my-ai-brain"
//! client_type = "claude-code"
//! attached_at = "2026-04-22T14:23:00Z"
//! ```
//!
//! Constructor Pattern: single responsibility — read/write the attach
//! marker. No knowledge of Brain schema or adapter behaviour.
//!
//! Testability: `$KEISEI_HOME` overrides `$HOME` (same convention as
//! `ClaudeCodeAdapter`) so integration tests isolate state per tmpdir.
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const ATTACHED_FILENAME: &str = "keisei-attached.toml";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AttachRecord {
pub brain_path: String,
pub brain_name: String,
pub client_type: String,
pub attached_at: String,
}
pub fn home_root() -> PathBuf {
let base = std::env::var("KEISEI_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| std::env::var("HOME").ok().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("."));
base.join(".claude")
}
pub fn attached_path() -> PathBuf {
home_root().join(ATTACHED_FILENAME)
}
pub fn write(rec: &AttachRecord) -> Result<PathBuf> {
let path = attached_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = toml::to_string_pretty(rec)?;
std::fs::write(&path, text)?;
Ok(path)
}
pub fn read() -> Result<Option<AttachRecord>> {
let path = attached_path();
if !path.is_file() {
return Ok(None);
}
let raw = std::fs::read_to_string(&path)?;
let rec: AttachRecord = toml::from_str(&raw)?;
Ok(Some(rec))
}
/// RFC-3339-ish UTC timestamp. Avoids a `chrono` dep for this one field —
/// we build `YYYY-MM-DDThh:mm:ssZ` from the platform SystemTime directly.
pub fn now_utc_string() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format_epoch_utc(secs)
}
fn format_epoch_utc(secs: u64) -> String {
// Civil-from-days (Howard Hinnant, date algorithms). Avoids chrono.
let days = (secs / 86400) as i64;
let rem = secs % 86400;
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let (y, mo, d) = civil_from_days(days);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, m, s)
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
// z = days since 1970-01-01.
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m as u32, d as u32)
}

View file

@ -0,0 +1,36 @@
//! Error type for the `keisei` CLI.
//!
//! Constructor Pattern: single responsibility — own all failure modes of the
//! attach / status flow as one thiserror enum. Every other module returns
//! `Result<T, Error>` using the `#[from]` conversions declared here.
use std::path::PathBuf;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("brain manifest not found at {0}")]
BrainNotFound(PathBuf),
#[error("brain schema version {found} not supported (need 1)")]
UnsupportedSchema { found: u32 },
#[error("no supported client detected in this directory")]
NoClientDetected,
#[error("no brain currently attached")]
NotAttached,
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
#[error("toml parse: {0}")]
TomlDe(#[from] toml::de::Error),
#[error("toml serialize: {0}")]
TomlSer(#[from] toml::ser::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}

View file

@ -0,0 +1,53 @@
//! keisei — exobrain attach/status CLI (MVP, Claude Code only).
//!
//! Constructor Pattern: main.rs = clap parse + dispatch only. All
//! subcommand logic lives in sibling modules (`attach.rs`, `status.rs`).
mod adapter;
mod adapters;
mod attach;
mod brain;
mod config;
mod error;
mod status;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser)]
#[command(
name = "keisei",
version,
about = "Exobrain attach/status — mount a portable brain into an AI client"
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Attach a brain directory to the currently detected AI client.
Attach {
/// Path to the brain directory (must contain manifest.toml).
brain_path: PathBuf,
},
/// Show the currently attached brain + health checks.
Status,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let res = match cli.cmd {
Cmd::Attach { brain_path } => attach::run(&brain_path),
Cmd::Status => status::run(),
};
match res {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("keisei: {e}");
ExitCode::from(1)
}
}
}

View file

@ -0,0 +1,62 @@
//! `keisei status` implementation.
//!
//! Constructor Pattern: single responsibility — read the `keisei-attached.toml`
//! SSoT, verify brain + mcp binary still exist, print a human-readable
//! summary with an `[OK]` / `[WARN]` health line.
use crate::brain::Brain;
use crate::config::{self, AttachRecord};
use crate::error::Result;
use std::path::PathBuf;
pub fn run() -> Result<()> {
match config::read()? {
None => {
println!("no brain attached");
println!("run: keisei attach <brain-path>");
Ok(())
}
Some(rec) => {
print_record(&rec);
print_health(&rec);
Ok(())
}
}
}
fn print_record(rec: &AttachRecord) {
println!("brain: {}", rec.brain_name);
println!("brain path: {}", rec.brain_path);
println!("client: {}", rec.client_type);
println!("attached at: {}", rec.attached_at);
}
fn print_health(rec: &AttachRecord) {
let brain_root = PathBuf::from(&rec.brain_path);
let brain_ok = brain_root.is_dir();
let mcp_ok = mcp_binary_ok(&brain_root);
if brain_ok && mcp_ok {
println!("health: [OK] brain dir exists, mcp binary exists");
} else {
println!(
"health: [WARN] brain_dir={}, mcp_binary={}",
health_mark(brain_ok),
health_mark(mcp_ok)
);
}
}
fn mcp_binary_ok(brain_root: &std::path::Path) -> bool {
match Brain::load(brain_root) {
Ok(b) => b.mcp_server_path().is_file(),
Err(_) => false,
}
}
fn health_mark(ok: bool) -> &'static str {
if ok {
"present"
} else {
"MISSING"
}
}

View file

@ -0,0 +1,142 @@
//! Integration tests for the `keisei` CLI primitives.
//!
//! Constructor Pattern: one scenario per test, one assertion target.
//! Each test runs with `KEISEI_HOME` pointed at a tempdir so nothing
//! touches the real `~/.claude`.
//!
//! Sources are loaded via `#[path]` — mirror of the kei-ledger pattern.
#[path = "../src/error.rs"]
mod error;
#[path = "../src/brain.rs"]
mod brain;
#[path = "../src/config.rs"]
mod config;
#[path = "../src/adapters/mod.rs"]
mod adapters;
#[path = "../src/adapter.rs"]
mod adapter;
#[path = "../src/attach.rs"]
mod attach;
#[path = "../src/status.rs"]
mod status;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tempfile::TempDir;
// `KEISEI_HOME` is process-global; tests must run serially around the
// env var. One global Mutex is enough for our few tests.
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
_home: TempDir,
}
fn setup_home() -> EnvGuard {
let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let home = tempfile::tempdir().unwrap();
// Ensure the Claude-Code adapter's `detect()` succeeds: it requires
// either CWD/.claude/settings.json OR $KEISEI_HOME/.claude/ to exist.
fs::create_dir_all(home.path().join(".claude")).unwrap();
std::env::set_var("KEISEI_HOME", home.path());
EnvGuard { _lock: lock, _home: home }
}
fn write_brain(root: &Path, schema: u32) -> PathBuf {
fs::create_dir_all(root.join("bin")).unwrap();
fs::write(root.join("bin/kei-mcp-server-test"), b"#!/bin/sh\n").unwrap();
let manifest = format!(
r#"[brain]
schema_version = {schema}
name = "test-brain"
created = "2026-04-22T00:00:00Z"
[paths]
memory = "memory/"
artifacts = "artifacts/"
manifests = "manifests/"
mcp_server = "bin/kei-mcp-server-test"
"#
);
fs::write(root.join("manifest.toml"), manifest).unwrap();
root.to_path_buf()
}
#[test]
fn attach_then_status_happy_path() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path()).expect("attach ok");
// Marker file exists with correct fields.
let rec = config::read().unwrap().expect("record present");
assert_eq!(rec.brain_name, "test-brain");
assert_eq!(rec.client_type, "claude-code");
assert!(rec.attached_at.ends_with('Z'));
// Status runs without error when attached.
status::run().expect("status ok after attach");
}
#[test]
fn attach_missing_manifest_errors() {
let _g = setup_home();
let empty = tempfile::tempdir().unwrap();
// No manifest.toml written.
let err = attach::run(empty.path()).unwrap_err();
assert!(
matches!(err, error::Error::BrainNotFound(_)),
"got {err:?}"
);
}
#[test]
fn attach_unsupported_schema_errors() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 99);
let err = attach::run(brain_dir.path()).unwrap_err();
assert!(
matches!(err, error::Error::UnsupportedSchema { found: 99 }),
"got {err:?}"
);
}
#[test]
fn status_without_attach_is_clean() {
let _g = setup_home();
// No marker file anywhere.
assert!(config::read().unwrap().is_none());
status::run().expect("status ok when not attached");
}
#[test]
fn attach_writes_marker_with_expected_fields() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path()).expect("attach ok");
let rec = config::read().unwrap().expect("record present");
// brain_path stored as canonicalized absolute path.
assert!(Path::new(&rec.brain_path).is_absolute());
assert_eq!(rec.brain_name, "test-brain");
assert_eq!(rec.client_type, "claude-code");
// Marker file itself lives under $KEISEI_HOME/.claude/.
let marker = config::attached_path();
assert!(marker.is_file(), "marker not written at {}", marker.display());
// Settings.json got written and contains the server entry.
let settings = marker.parent().unwrap().join("settings.json");
assert!(settings.is_file(), "settings.json not written");
let text = fs::read_to_string(&settings).unwrap();
assert!(text.contains("mcpServers"), "mcpServers key missing");
assert!(text.contains("test-brain"), "brain-name key missing");
}