Merge feat/v0.18-keisei-cli-mvp — exobrain attach/status CLI
This commit is contained in:
commit
e53ad26243
15 changed files with 736 additions and 1 deletions
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ members = [
|
|||
"kei-auth",
|
||||
# v0.15 artifact handoff pipeline
|
||||
"kei-artifact",
|
||||
# v0.18 exobrain CLI
|
||||
"keisei",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
21
_primitives/_rust/keisei/Cargo.toml
Normal file
21
_primitives/_rust/keisei/Cargo.toml
Normal 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"
|
||||
38
_primitives/_rust/keisei/src/adapter.rs
Normal file
38
_primitives/_rust/keisei/src/adapter.rs
Normal 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)
|
||||
}
|
||||
121
_primitives/_rust/keisei/src/adapters/claude_code.rs
Normal file
121
_primitives/_rust/keisei/src/adapters/claude_code.rs
Normal 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(())
|
||||
}
|
||||
6
_primitives/_rust/keisei/src/adapters/mod.rs
Normal file
6
_primitives/_rust/keisei/src/adapters/mod.rs
Normal 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;
|
||||
44
_primitives/_rust/keisei/src/attach.rs
Normal file
44
_primitives/_rust/keisei/src/attach.rs
Normal 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");
|
||||
}
|
||||
103
_primitives/_rust/keisei/src/brain.rs
Normal file
103
_primitives/_rust/keisei/src/brain.rs
Normal 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
|
||||
}
|
||||
}
|
||||
97
_primitives/_rust/keisei/src/config.rs
Normal file
97
_primitives/_rust/keisei/src/config.rs
Normal 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)
|
||||
}
|
||||
36
_primitives/_rust/keisei/src/error.rs
Normal file
36
_primitives/_rust/keisei/src/error.rs
Normal 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),
|
||||
}
|
||||
53
_primitives/_rust/keisei/src/main.rs
Normal file
53
_primitives/_rust/keisei/src/main.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
62
_primitives/_rust/keisei/src/status.rs
Normal file
62
_primitives/_rust/keisei/src/status.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
142
_primitives/_rust/keisei/tests/integration.rs
Normal file
142
_primitives/_rust/keisei/tests/integration.rs
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue