diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed38cd..cabb3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` + `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`). diff --git a/README.md b/README.md index 64c0f65..9d30e8a 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,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) diff --git a/_primitives/MANIFEST.toml b/_primitives/MANIFEST.toml index 3448dcd..a8ed621 100644 --- a/_primitives/MANIFEST.toml +++ b/_primitives/MANIFEST.toml @@ -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)" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index bc1a04f..4c7f8cd 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -27,6 +27,8 @@ members = [ "kei-auth", # v0.15 artifact handoff pipeline "kei-artifact", + # v0.18 exobrain CLI + "keisei", ] [workspace.package] diff --git a/_primitives/_rust/keisei/Cargo.toml b/_primitives/_rust/keisei/Cargo.toml new file mode 100644 index 0000000..472117b --- /dev/null +++ b/_primitives/_rust/keisei/Cargo.toml @@ -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" diff --git a/_primitives/_rust/keisei/src/adapter.rs b/_primitives/_rust/keisei/src/adapter.rs new file mode 100644 index 0000000..661d80b --- /dev/null +++ b/_primitives/_rust/keisei/src/adapter.rs @@ -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> { + vec![Box::new(ClaudeCodeAdapter::new())] +} + +/// Return the first adapter whose `detect()` fires. `NoClientDetected` +/// otherwise. +pub fn detect_active() -> Result> { + for a in all() { + if a.detect() { + return Ok(a); + } + } + Err(Error::NoClientDetected) +} diff --git a/_primitives/_rust/keisei/src/adapters/claude_code.rs b/_primitives/_rust/keisei/src/adapters/claude_code.rs new file mode 100644 index 0000000..4d12c0c --- /dev/null +++ b/_primitives/_rust/keisei/src/adapters/claude_code.rs @@ -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.` 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.`. +/// 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(()) +} diff --git a/_primitives/_rust/keisei/src/adapters/mod.rs b/_primitives/_rust/keisei/src/adapters/mod.rs new file mode 100644 index 0000000..2e0a2fb --- /dev/null +++ b/_primitives/_rust/keisei/src/adapters/mod.rs @@ -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; diff --git a/_primitives/_rust/keisei/src/attach.rs b/_primitives/_rust/keisei/src/attach.rs new file mode 100644 index 0000000..9f2e975 --- /dev/null +++ b/_primitives/_rust/keisei/src/attach.rs @@ -0,0 +1,44 @@ +//! `keisei attach ` 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"); +} diff --git a/_primitives/_rust/keisei/src/brain.rs b/_primitives/_rust/keisei/src/brain.rs new file mode 100644 index 0000000..8abad1b --- /dev/null +++ b/_primitives/_rust/keisei/src/brain.rs @@ -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, +} + +#[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 `/manifest.toml`. + /// Canonicalizes `root` to an absolute path and validates schema_version. + pub fn load(root: &Path) -> Result { + 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 + } +} diff --git a/_primitives/_rust/keisei/src/config.rs b/_primitives/_rust/keisei/src/config.rs new file mode 100644 index 0000000..4c4e01e --- /dev/null +++ b/_primitives/_rust/keisei/src/config.rs @@ -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 { + 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> { + 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) +} diff --git a/_primitives/_rust/keisei/src/error.rs b/_primitives/_rust/keisei/src/error.rs new file mode 100644 index 0000000..05cf753 --- /dev/null +++ b/_primitives/_rust/keisei/src/error.rs @@ -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` using the `#[from]` conversions declared here. + +use std::path::PathBuf; + +pub type Result = std::result::Result; + +#[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), +} diff --git a/_primitives/_rust/keisei/src/main.rs b/_primitives/_rust/keisei/src/main.rs new file mode 100644 index 0000000..9777ae8 --- /dev/null +++ b/_primitives/_rust/keisei/src/main.rs @@ -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) + } + } +} diff --git a/_primitives/_rust/keisei/src/status.rs b/_primitives/_rust/keisei/src/status.rs new file mode 100644 index 0000000..b8d0108 --- /dev/null +++ b/_primitives/_rust/keisei/src/status.rs @@ -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 "); + 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" + } +} diff --git a/_primitives/_rust/keisei/tests/integration.rs b/_primitives/_rust/keisei/tests/integration.rs new file mode 100644 index 0000000..dfb5a70 --- /dev/null +++ b/_primitives/_rust/keisei/tests/integration.rs @@ -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"); +}