Merge feat/v0.22-keisei-schema-v4 — schema v4 + Scope::Auto + registry (conflicts resolved)

CHANGELOG: merged Track A (schema v4 + Auto + registry + time.rs split) and Track C (fs_type.rs + battle-matrix + USB guide split) Added entries side-by-side. Kept Track A's Removed block.

integration.rs: 2 conflict blocks merged by concatenation — Track C's
fs_type detection tests (2) and Track A's schema v4 / multi-brain /
Scope::Auto / templated hint / registry tests (16). Total 48 tests pass
(46 Track A + 2 Track C).

Missing closing braces on both merge boundaries repaired
(auto-merge dropped '); }' at 2 seams).
This commit is contained in:
Parfii-bot 2026-04-22 21:06:23 +08:00
commit 27c153cefb
19 changed files with 986 additions and 289 deletions

View file

@ -20,10 +20,34 @@ _primitives/_rust/target/release/kei-changelog \
> Only placeholders — no corresponding commits exist yet. Any line that
> ships must be replaced with the real commit summary before release.
### Changed
- **primitives (v0.22 — `keisei` schema v4, BREAKING marker shape):**
- Marker schema bumped from v3 to v4. The top-level `brain_path` / `brain_name` / `attached_at` fields are gone; every `[[attachments]]` entry now owns its own `brain_path`, `brain_name`, and `attached_at`. Consequence: one marker can track multiple brains wired to different clients at the same time (e.g. brain-A on Claude Code at user scope + brain-B on Cursor at project scope). `config::AttachRecord::new(attachments)` is the fresh constructor; raw struct literals no longer compile.
- `ClientAdapter::post_attach_hint` signature widened from `fn (&self) -> &'static str` to `fn (&self, brain: &Brain, scope: Scope) -> String` so adapters can interpolate the brain's name and the resolved scope into the reload instruction. Implementations of the trait outside this crate need to update.
- Adapter enumeration centralised in `adapters/_registry.rs::all_adapters`. `adapter::all()` now delegates; the "add a 5th adapter" touchpoint drops from three files to one. Public API of `adapter::all()` unchanged.
- **primitives (v0.22 — `config.rs` decomposition):**
- Schema-migration logic extracted from `config.rs` into `config_migrate.rs` (pure functions on `WireRecord``AttachRecord`). Time helpers extracted into `time.rs` with a 5-test anchor suite covering epoch-0, leap day 2020-02-29, century non-leap 2100-03-01, an arbitrary recent timestamp, and `civil_from_days` direct invariants. `config.rs` drops from 224 LOC to 197 LOC, below the 200-LOC Constructor Pattern ceiling.
### Added
- **primitives/keisei (v0.22 Track C — filesystem-type advisory):** new `fs_type.rs` cube (`<110 LOC`) classifies the brain root via `statfs(2)` on macOS + Linux and returns `FsWarning::{None,ExFat,Fat32,Unknown}`. Windows support deferred (returns `Unknown` until `GetVolumeInformationW` lands). `Brain::load` now prints a stderr advisory when exFAT / FAT32 is detected SQLite WAL shared-mmap is unreliable there and `keisei mount` (multi-client) WILL corrupt `kei-memory` / `kei-artifact` / `kei-social-store` DBs. Warning is non-blocking single-client `keisei attach` on exFAT stays supported. New runtime dep `libc = "0.2"` (unix-only). Two new integration tests (`brain_load_on_typical_filesystem_no_warn`, `fs_type_detection_returns_none_on_standard_fs`) suite now 32/32 pass.
- **primitives/keisei (v0.22 Track A — schema v4 multi-brain marker):** `AttachRecord` inverted so every `Attachment` carries its own `brain_path` + `brain_name` + `scope` + `attached_at`. Enables brain-A attached to Claude Code (user scope) + brain-B attached to Cursor (project scope) simultaneously in ONE marker. v1/v2/v3 readers transparent via `#[serde(untagged)]` — auto-migrate silently on first v0.22 `config::read()` with one-line stderr notice.
- **primitives (v0.22 — `Scope::Auto` CLI default):**
- `keisei attach <brain>` without `--scope` now defaults to `auto`. Each adapter exposes `auto_scope()`; Claude Code returns `Scope::Project` when CWD has `.claude/` (dir or `settings.json`), Cursor when CWD has `.cursor/`. Continue + Zed stay on user-scope default. Team workflow `cd team-repo; keisei attach brain` now picks project-scope without an extra flag.
- `Scope::Auto` is a CLI-level intent only — `attach.rs` resolves it to a concrete `User` / `Project` before writing the marker. The persisted marker never contains `auto`.
- **primitives (v0.22 — `keisei mount` per-adapter scope resolution):**
- `mount` now resolves scope per-adapter via `auto_scope()` instead of forcing `Scope::User` across the fan-out. A single `keisei mount brain` inside `team-repo/` can wire Cursor at project scope and Claude Code at user scope (or both at project, depending on CWD).
- **primitives (v0.22 — templated `post_attach_hint`):** `fn post_attach_hint(&self, brain: &Brain, scope: Scope) -> String` interpolates the brain name + resolved scope into the per-adapter reload instruction. No more Claude-Code-specific string in the orchestrator.
- **primitives (v0.22 — adapter registry):** new `adapters/_registry.rs` (32 LOC) is the single canonical adapter list. `adapter::all()` delegates. Adding a 5th adapter is one line, one place.
- **primitives (v0.22 — `config.rs` decomposition):** `config.rs` 224 → 197 LOC. Extracted `time.rs` (90 LOC — `now_utc_string` + `format_epoch_utc` + `civil_from_days` + 5 unit tests covering epoch-0, leap-day 2020-02-29, century-non-leap 2100-03-01, arbitrary 2026-04-22, RFC3339 shape) and `config_migrate.rs` (114 LOC — `WireRecord` v1→v4 migration).
- **primitives/keisei (v0.22 Track C — filesystem-type advisory):** new `fs_type.rs` cube (`<110 LOC`) classifies the brain root via `statfs(2)` on macOS + Linux and returns `FsWarning::{None,ExFat,Fat32,Unknown}`. Windows support deferred (returns `Unknown` until `GetVolumeInformationW` lands). `Brain::load` now prints a stderr advisory when exFAT / FAT32 is detected SQLite WAL shared-mmap is unreliable there and `keisei mount` (multi-client) WILL corrupt `kei-memory` / `kei-artifact` / `kei-social-store` DBs. Warning is non-blocking single-client `keisei attach` on exFAT stays supported. New runtime dep `libc = "0.2"` (unix-only). Two new integration tests (`brain_load_on_typical_filesystem_no_warn`, `fs_type_detection_returns_none_on_standard_fs`).
- **tests/battle (v0.22 Track C — distro matrix):** two new Dockerfiles alongside the existing `ubuntu:24.04` image. `Dockerfile.install-test-alpine` (Alpine 3.19 — musl libc, exposes musl-static-link quirks in `rusqlite` / `git2` / `aws-sdk-s3`). `Dockerfile.install-test-debian` (Debian 12 bookworm — glibc, different apt structure from Ubuntu). `README.md` documents the 3-image matrix and documents known musl-static-link failures as matrix signal rather than regression.
- **docs (v0.22 Track C — USB guide platform split):** `USB-BRAIN-GUIDE.md` restructured into a TOC + platform-agnostic preamble (prerequisites, exFAT/FAT32 warning, invariants, troubleshooting). Three new platform-specific walkthroughs: `USB-BRAIN-GUIDE-macos.md` (Gatekeeper `xattr`, `/Volumes/`, `diskutil`), `USB-BRAIN-GUIDE-linux.md` (`/media/$USER/`, `umount`, ext4, optional systemd-udev auto-attach), `USB-BRAIN-GUIDE-windows.md` (PowerShell, drive letter, NTFS, `Dismount-Volume`, FS-advisory returns `Unknown` caveat).
### Removed
- **primitives (v0.22 — dead `Error` variants):**
- `Error::NotAttached` — never surfaced; `detach` prints "nothing to detach" and returns `Ok(())`.
- `Error::AdapterFailed { client, reason }` — never constructed; `mount` / `detach` orchestration carried `(client, reason)` tuples instead. Downstream matches on these variants won't compile against v0.22.
### Added (pre-v0.22)
- **primitives (v0.21 — keisei SSoT relocation + `Scope` enum):**
- Marker file relocated from `~/.claude/keisei-attached.toml` to `~/.keisei/attached.toml`. `~/.claude/` is Claude-Code-specific territory and should not host cross-adapter keisei state. `config::read()` performs a one-shot migration the first time it runs under v0.21: if the legacy file exists and the new location is empty, the marker moves over (new file written, legacy file deleted) and a stderr notice is emitted.
- `Scope` enum (`user` / `project`) on the `ClientAdapter` trait. Adapters declare `supported_scopes()`; `config_path(scope)`, `attach(brain, scope)`, `detach(brain_name, scope)` are scope-aware. Claude Code and Cursor support both scopes; Continue and Zed are user-only. `keisei attach` gains `--scope=<user|project>` (default `user`); `keisei mount` stays host-wide (`Scope::User` fan-out by design).

View file

@ -3,19 +3,24 @@
//! Constructor Pattern: this file owns the trait + the "enumerate all
//! adapters" function + lookup-by-name helper. Each concrete adapter
//! lives in its own file under `adapters/`. `Scope` itself lives in
//! `scope.rs` (own file, own concept).
//! `scope.rs` (own file, own concept). The adapter list lives in
//! `adapters/_registry.rs` — this file delegates via `all()` so the
//! public API stays stable when adapters are added.
//!
//! v0.21: trait gained `Scope` parameter — adapters with both host-wide
//! and per-project config surfaces (claude-code, cursor) can be driven
//! to either location from one code path. Adapters that only expose a
//! global config (continue, zed) declare `supported_scopes() = [User]`.
//!
//! v0.22:
//! * `auto_scope()` — adapter-driven CWD heuristic that turns
//! `Scope::Auto` into a concrete `User` / `Project`. Default is
//! `Scope::User` (safe fallback); Claude Code + Cursor override.
//! * `post_attach_hint(brain, scope)` — templated hint so the CLI can
//! interpolate the brain's name and the resolved scope into the
//! client-specific reload instruction. Returns `String` (not
//! `&'static str`) to accommodate `format!(...)`.
use crate::adapters::{
claude_code::ClaudeCodeAdapter,
continue_adapter::ContinueAdapter,
cursor::CursorAdapter,
zed::ZedAdapter,
};
use crate::brain::Brain;
use crate::error::{Error, Result};
use crate::scope::Scope;
@ -32,6 +37,17 @@ pub trait ClientAdapter {
&[Scope::User]
}
/// Resolve `Scope::Auto` into a concrete scope via adapter-specific CWD
/// heuristics. Default: `Scope::User` — adapters that understand a
/// project-local config surface (claude-code, cursor) override this to
/// return `Scope::Project` when the CWD has the matching dot-dir.
///
/// Only called by the attach flow when the user passed `Scope::Auto`;
/// the resolved value is what lands in the marker.
fn auto_scope(&self) -> Scope {
Scope::User
}
fn config_path(&self, scope: Scope) -> PathBuf;
fn attach(&self, brain: &Brain, scope: Scope) -> Result<()>;
fn detach(&self, brain_name: &str, scope: Scope) -> Result<()>;
@ -41,26 +57,28 @@ pub trait ClientAdapter {
/// 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"
/// strings. Takes `&Brain` and `Scope` so adapters can interpolate
/// the brain name and the resolved scope into the message.
fn post_attach_hint(&self, _brain: &Brain, _scope: Scope) -> String {
"reload your AI client to pick up the new MCP server".to_string()
}
/// Helper: does this adapter support the given scope?
/// `Scope::Auto` always "supported" here — scope resolution happens
/// in `attach.rs` before this check runs against a concrete scope.
fn supports_scope(&self, scope: Scope) -> bool {
if matches!(scope, Scope::Auto) {
return true;
}
self.supported_scopes().contains(&scope)
}
}
/// Enumerate all adapters the binary knows about, in priority order.
/// Order matters: `detect_active()` returns the first positive hit.
/// Thin delegate to `adapters::_registry::all_adapters`, which is the
/// canonical list (Constructor Pattern single-point-of-edit).
pub fn all() -> Vec<Box<dyn ClientAdapter>> {
vec![
Box::new(ClaudeCodeAdapter::new()),
Box::new(CursorAdapter::new()),
Box::new(ContinueAdapter::new()),
Box::new(ZedAdapter::new()),
]
crate::adapters::_registry::all_adapters()
}
/// Return the first adapter whose `detect()` fires. `NoClientDetected`

View file

@ -0,0 +1,32 @@
//! Adapter registry — the ONE place to add a new `ClientAdapter`.
//!
//! Constructor Pattern: single responsibility — own the concrete list of
//! adapters the binary knows about, in priority order. `adapter::all()`
//! delegates here so callers never have to edit two files when a fifth
//! adapter ships.
//!
//! Adding a 5th adapter: create its file under `adapters/<name>.rs`,
//! register the module in `adapters/mod.rs`, and add one `Box::new(...)`
//! line below. That's it — `detect_active`, `by_name`, `list-adapters`,
//! `mount`, and `detach` all pick it up automatically.
//!
//! Rationale for NOT using the `inventory` crate yet: at the 4→5 scale we
//! don't pay the dependency cost; a plain function is cheaper and easier
//! to audit.
use super::claude_code::ClaudeCodeAdapter;
use super::continue_adapter::ContinueAdapter;
use super::cursor::CursorAdapter;
use super::zed::ZedAdapter;
use crate::adapter::ClientAdapter;
/// Enumerate every adapter the binary knows about, in priority order.
/// Order matters: `detect_active()` returns the first positive hit.
pub fn all_adapters() -> Vec<Box<dyn ClientAdapter>> {
vec![
Box::new(ClaudeCodeAdapter::new()),
Box::new(CursorAdapter::new()),
Box::new(ContinueAdapter::new()),
Box::new(ZedAdapter::new()),
]
}

View file

@ -48,6 +48,9 @@ impl ClaudeCodeAdapter {
Scope::Project => self
.project_config_dir()
.unwrap_or_else(|| PathBuf::from(".claude")),
// `Auto` is a CLI-level intent, resolved before any adapter
// call. If one leaks here, treat it as the safe default.
Scope::Auto => self.user_config_dir(),
}
}
}
@ -74,6 +77,21 @@ impl ClientAdapter for ClaudeCodeAdapter {
&[Scope::User, Scope::Project]
}
fn auto_scope(&self) -> Scope {
// Project-scope if the CWD carries a `.claude/` dir (either
// `settings.json` already in place, OR an empty `.claude/`
// directory the user has scaffolded). Otherwise user-scope.
let Ok(cwd) = std::env::current_dir() else {
return Scope::User;
};
let dot_claude = cwd.join(".claude");
if dot_claude.join("settings.json").is_file() || dot_claude.is_dir() {
Scope::Project
} else {
Scope::User
}
}
fn attach(&self, brain: &Brain, scope: Scope) -> Result<()> {
let cfg = self.config_path(scope);
if let Some(parent) = cfg.parent() {
@ -99,7 +117,11 @@ impl ClientAdapter for ClaudeCodeAdapter {
self.dir_for_scope(scope).join("settings.json")
}
fn post_attach_hint(&self) -> &str {
"run /help in Claude Code to verify the MCP server is reachable"
fn post_attach_hint(&self, brain: &Brain, scope: Scope) -> String {
format!(
"run /help in Claude Code ({} scope) — verify '{}' is in mcpServers",
scope,
brain.name()
)
}
}

View file

@ -111,8 +111,11 @@ impl ClientAdapter for ContinueAdapter {
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"
fn post_attach_hint(&self, brain: &Brain, _scope: Scope) -> String {
format!(
"reload the Continue extension in VS Code — '{}' goes under Experimental MCP",
brain.name()
)
}
}

View file

@ -48,6 +48,9 @@ impl CursorAdapter {
Scope::Project => self
.project_cursor_dir()
.unwrap_or_else(|| PathBuf::from(".cursor")),
// `Auto` is a CLI-level intent, resolved before any adapter
// call. Safe fallback is user scope.
Scope::Auto => self.home_cursor_dir(),
}
}
}
@ -75,6 +78,19 @@ impl ClientAdapter for CursorAdapter {
&[Scope::User, Scope::Project]
}
fn auto_scope(&self) -> Scope {
// Project-scope if `./.cursor/` exists in the CWD. Mirrors the
// claude-code heuristic shape.
let Ok(cwd) = std::env::current_dir() else {
return Scope::User;
};
if cwd.join(".cursor").is_dir() {
Scope::Project
} else {
Scope::User
}
}
fn attach(&self, brain: &Brain, scope: Scope) -> Result<()> {
let cfg = self.config_path(scope);
if let Some(parent) = cfg.parent() {
@ -100,7 +116,10 @@ impl ClientAdapter for CursorAdapter {
self.dir_for_scope(scope).join("mcp.json")
}
fn post_attach_hint(&self) -> &str {
"reload Cursor window (Cmd+Shift+P → 'Reload Window') to pick up the MCP server"
fn post_attach_hint(&self, brain: &Brain, _scope: Scope) -> String {
format!(
"reload Cursor window (Cmd+Shift+P → Reload Window) — '{}' should appear in MCP servers list",
brain.name()
)
}
}

View file

@ -3,7 +3,10 @@
//! Constructor Pattern: this file is the module declaration hub only —
//! no logic lives here. `jsonmcp` owns the shared JSON merge helpers
//! used by every JSON-keyed adapter (claude-code, cursor, zed).
//! `_registry` is the single canonical adapter list (v0.22).
#[path = "_registry.rs"]
pub mod _registry;
pub mod claude_code;
pub mod continue_adapter;
pub mod cursor;

View file

@ -108,7 +108,10 @@ impl ClientAdapter for ZedAdapter {
self.settings_file()
}
fn post_attach_hint(&self) -> &str {
"run Zed's :reload command to pick up the MCP server config"
fn post_attach_hint(&self, brain: &Brain, _scope: Scope) -> String {
format!(
"run Zed ':reload' — '{}' registers under context_servers",
brain.name()
)
}
}

View file

@ -1,10 +1,18 @@
//! `keisei attach <brain-path> [--scope=<user|project>]` implementation.
//! `keisei attach <brain-path> [--scope=<user|project|auto>]` 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.
//! detect client → resolve Auto scope → adapter.attach → merge into SSoT
//! marker → print summary). No I/O here beyond what the `brain`,
//! `adapter`, and `config` modules already own.
//!
//! v0.22:
//! * `Scope::Auto` (CLI default) is resolved into a concrete `User` /
//! `Project` by the adapter's `auto_scope()` before the attach runs —
//! the marker never stores `Auto`.
//! * The marker merges v4-style: if a v4 marker already exists, the new
//! attachment is appended (or replaced if `(client_type, scope)`
//! already matches); otherwise a fresh marker is written.
use crate::adapter::{detect_active, ClientAdapter};
use crate::brain::Brain;
@ -17,14 +25,26 @@ use std::path::Path;
pub fn run(brain_path: &Path, scope: Scope) -> Result<()> {
let brain = Brain::load(brain_path)?;
let adapter = detect_active()?;
ensure_scope_supported(adapter.as_ref(), scope)?;
adapter.attach(&brain, scope)?;
let rec = build_record(&brain, adapter.as_ref(), scope);
let resolved = resolve_scope(adapter.as_ref(), scope);
ensure_scope_supported(adapter.as_ref(), resolved)?;
adapter.attach(&brain, resolved)?;
let attachment = build_attachment(&brain, adapter.as_ref(), resolved);
let rec = merge_into_marker(attachment)?;
let marker = config::write(&rec)?;
print_summary(&brain, adapter.as_ref(), scope, &marker);
print_summary(&brain, adapter.as_ref(), resolved, &marker);
Ok(())
}
/// If the user passed `Scope::Auto`, ask the adapter to pick based on
/// its CWD heuristic. Otherwise return the scope unchanged.
fn resolve_scope(adapter: &dyn ClientAdapter, scope: Scope) -> Scope {
if matches!(scope, Scope::Auto) {
adapter.auto_scope()
} else {
scope
}
}
fn ensure_scope_supported(adapter: &dyn ClientAdapter, scope: Scope) -> Result<()> {
if adapter.supports_scope(scope) {
return Ok(());
@ -40,19 +60,29 @@ fn ensure_scope_supported(adapter: &dyn ClientAdapter, scope: Scope) -> Result<(
})
}
fn build_record(brain: &Brain, adapter: &dyn ClientAdapter, scope: Scope) -> AttachRecord {
AttachRecord {
fn build_attachment(brain: &Brain, adapter: &dyn ClientAdapter, scope: Scope) -> Attachment {
Attachment {
brain_path: brain.root.to_string_lossy().into_owned(),
brain_name: brain.name().to_string(),
client_type: adapter.name().to_string(),
config_path: adapter.config_path(scope).to_string_lossy().into_owned(),
scope,
attached_at: config::now_utc_string(),
attachments: vec![Attachment {
client_type: adapter.name().to_string(),
config_path: adapter.config_path(scope).to_string_lossy().into_owned(),
scope,
}],
}
}
/// Merge a new attachment into the existing marker, or start fresh.
/// Replaces any prior attachment with the same `(client_type, scope)` —
/// re-attaching to the same client+scope updates the entry in place.
fn merge_into_marker(new_attachment: Attachment) -> Result<AttachRecord> {
let mut existing = config::read()?.map(|r| r.attachments).unwrap_or_default();
existing.retain(|a| {
!(a.client_type == new_attachment.client_type && a.scope == new_attachment.scope)
});
existing.push(new_attachment);
Ok(AttachRecord::new(existing))
}
fn print_summary(
brain: &Brain,
adapter: &dyn ClientAdapter,
@ -80,5 +110,6 @@ fn print_summary(
}
println!(" client cfg: {}", adapter.config_path(scope).display());
println!(" marker: {}", marker.display());
println!("{}", adapter.post_attach_hint());
let hint = adapter.post_attach_hint(brain, scope);
println!("{}", sanitize_display(&hint));
}

View file

@ -1,40 +1,35 @@
//! SSoT for the active attach: `~/.keisei/attached.toml` (v0.21+).
//! SSoT for the active attach: `~/.keisei/attached.toml` (v0.22+).
//!
//! Schema v3 (v0.21, scope-aware):
//! Schema v4 (v0.22, multi-brain per marker):
//! ```toml
//! brain_path = "/Volumes/Brain1"
//! brain_name = "my-ai-brain"
//! attached_at = "2026-04-22T14:23:00Z"
//! schema_version = 4
//!
//! [[attachments]]
//! brain_path = "/Volumes/Brain1"
//! brain_name = "brain-a"
//! client_type = "claude-code"
//! config_path = "/Users/me/.claude/settings.json"
//! scope = "user"
//!
//! [[attachments]]
//! client_type = "cursor"
//! config_path = "/Users/me/proj/.cursor/mcp.json"
//! scope = "project"
//! attached_at = "2026-04-22T14:23:00Z"
//! ```
//!
//! Schema v2 (v0.19, multi-client, no `scope`) and v1 (v0.18, single-client
//! flat `client_type`) still read transparently. Old `scope`-less entries
//! default to `Scope::User`.
//!
//! Location migration (v0.20 → v0.21): on first `read()`, if the NEW path
//! `~/.keisei/attached.toml` does not exist but the OLD path
//! `~/.claude/keisei-attached.toml` does, we move the file (write new,
//! delete old) and emit a one-line stderr notice.
//! Older schemas (v1/v2/v3) still read transparently — migrated in-memory
//! to v4 on first `read()` (see `config_migrate.rs`). One-line stderr
//! notice fires so operators see the shape flip. Location migration
//! (v0.20 legacy path `~/.claude/keisei-attached.toml` → v0.21 path
//! `~/.keisei/attached.toml`) happens in the same pass.
//!
//! Constructor Pattern: single responsibility — read/write the attach
//! marker + one-shot location migration. No knowledge of Brain schema or
//! adapter behaviour.
//! marker + one-shot location migration. Schema migration lives in
//! `config_migrate.rs`; time helpers in `time.rs`.
//!
//! Testability: `$KEISEI_HOME` overrides `$HOME` so integration tests
//! isolate state per tmpdir.
use crate::config_migrate::WireRecord;
use crate::error::Result;
use crate::scope::Scope;
use crate::time;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
@ -44,77 +39,59 @@ pub const ATTACHED_FILENAME: &str = "attached.toml";
/// Legacy (v0.20 and earlier) filename, under `$KEISEI_HOME/.claude/`.
pub const LEGACY_ATTACHED_FILENAME: &str = "keisei-attached.toml";
/// Current on-disk schema version.
pub const CURRENT_SCHEMA: u32 = 4;
/// A single brain ⇄ client attachment. v4 pulls `brain_path`, `brain_name`,
/// and `attached_at` INTO the attachment so one marker can track multiple
/// brains wired to different clients simultaneously.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct Attachment {
pub brain_path: String,
pub brain_name: String,
pub client_type: String,
pub config_path: String,
#[serde(default)]
pub scope: Scope,
pub attached_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Clone)]
pub struct AttachRecord {
pub brain_path: String,
pub brain_name: String,
pub attached_at: String,
#[serde(default)]
pub schema_version: u32,
pub attachments: Vec<Attachment>,
}
impl AttachRecord {
/// Convenience: are we attached to the given client?
///
/// Bin never calls this directly today (detach iterates `attachments`
/// instead), but integration tests use it as a semantic assertion and
/// it's part of the public shape.
pub fn new(attachments: Vec<Attachment>) -> Self {
Self {
schema_version: CURRENT_SCHEMA,
attachments,
}
}
#[allow(dead_code)]
pub fn has_client(&self, client: &str) -> bool {
self.attachments.iter().any(|a| a.client_type == client)
}
/// List of attached client names in declaration order.
#[allow(dead_code)]
pub fn client_names(&self) -> Vec<String> {
self.attachments
.iter()
.map(|a| a.client_type.clone())
.collect()
}
}
/// Raw wire shape: accepts v1 (flat `client_type`), v2 (list without scope),
/// and v3 (list with scope).
#[derive(Debug, Deserialize)]
struct WireRecord {
brain_path: String,
brain_name: String,
attached_at: String,
#[serde(default)]
client_type: Option<String>,
#[serde(default)]
attachments: Vec<Attachment>,
}
impl WireRecord {
fn into_current(self) -> AttachRecord {
let attachments = if !self.attachments.is_empty() {
self.attachments
} else if let Some(ct) = self.client_type {
// v1 → current migration. config_path unknown at migration time;
// leave blank — adapter will re-derive from `config_path()`.
vec![Attachment {
client_type: ct,
config_path: String::new(),
scope: Scope::User,
}]
} else {
Vec::new()
};
AttachRecord {
brain_path: self.brain_path,
brain_name: self.brain_name,
attached_at: self.attached_at,
attachments,
#[allow(dead_code)]
pub fn brain_names(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for a in &self.attachments {
if !out.contains(&a.brain_name) {
out.push(a.brain_name.clone());
}
}
out
}
}
@ -128,9 +105,7 @@ pub fn attached_path() -> PathBuf {
keisei_state_dir().join(ATTACHED_FILENAME)
}
/// Legacy marker path (v0.20 and earlier):
/// `$KEISEI_HOME/.claude/keisei-attached.toml`. Kept as a read-only helper
/// for the one-shot migration inside `read()`.
/// Legacy marker path (v0.20 and earlier).
pub fn legacy_attached_path() -> PathBuf {
crate::paths::resolve_home()
.join(".claude")
@ -144,20 +119,14 @@ pub fn write(rec: &AttachRecord) -> Result<PathBuf> {
}
let text = toml::to_string_pretty(rec)?;
std::fs::write(&path, text)?;
// M1 (v0.19.2 audit): marker holds the brain_path + attached client list
// — restrict to owner-only on unix. Windows has no equivalent bit.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&path, perms)?;
}
apply_owner_perms(&path)?;
Ok(path)
}
/// Read the marker, performing one-shot v0.20→v0.21 location migration if
/// the legacy file exists and the new file does not.
/// the legacy file exists and the new file does not. Older schemas
/// (v1/v2/v3) are migrated in-memory to v4 on read, with a one-line
/// stderr notice so operators see the shape flip.
pub fn read() -> Result<Option<AttachRecord>> {
migrate_from_legacy()?;
let path = attached_path();
@ -166,7 +135,13 @@ pub fn read() -> Result<Option<AttachRecord>> {
}
let raw = std::fs::read_to_string(&path)?;
let wire: WireRecord = toml::from_str(&raw)?;
Ok(Some(wire.into_current()))
let (rec, old_version) = wire.into_current();
if let Some(from) = old_version {
eprintln!(
"keisei: migrated marker shape v{from} → v{CURRENT_SCHEMA} (in-memory; run any attach/detach to persist)"
);
}
Ok(Some(rec))
}
pub fn delete() -> Result<bool> {
@ -180,8 +155,6 @@ pub fn delete() -> Result<bool> {
/// If `~/.claude/keisei-attached.toml` exists AND `~/.keisei/attached.toml`
/// does not, move the marker to the new location and emit a stderr notice.
/// Safe to call every time; it's a no-op once the new file exists or the
/// legacy file is absent.
pub fn migrate_from_legacy() -> Result<()> {
let legacy = legacy_attached_path();
let current = attached_path();
@ -193,13 +166,7 @@ pub fn migrate_from_legacy() -> Result<()> {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&current, &raw)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&current)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&current, perms)?;
}
apply_owner_perms(&current)?;
std::fs::remove_file(&legacy)?;
eprintln!(
"keisei: migrated marker from ~/.claude/keisei-attached.toml to ~/.keisei/attached.toml"
@ -207,37 +174,24 @@ pub fn migrate_from_legacy() -> Result<()> {
Ok(())
}
/// 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.
/// On unix, restrict the marker to owner-only (0o600). No-op on windows.
fn apply_owner_perms(path: &std::path::Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)?;
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
/// Thin re-export so call-sites elsewhere in the crate don't have to
/// learn about the new `time` module.
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)
time::now_utc_string()
}

View file

@ -0,0 +1,114 @@
//! Schema-migration logic for the attach marker.
//!
//! Constructor Pattern: single responsibility — own the `WireRecord`
//! enum and its v1/v2/v3 → v4 lift. Extracted from `config.rs` in v0.22
//! so `config.rs` stays under the 200-LOC ceiling.
//!
//! Serde's `untagged` discrimination picks the first variant that
//! deserializes cleanly. Order: v4 first (strictest — carries
//! `schema_version` field), then v1/v2/v3 legacy shapes.
use crate::config::{AttachRecord, Attachment, CURRENT_SCHEMA};
use crate::scope::Scope;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum WireRecord {
V4(WireV4),
Legacy(WireLegacy),
}
#[derive(Debug, Deserialize)]
pub(crate) struct WireV4 {
pub schema_version: u32,
#[serde(default)]
pub attachments: Vec<Attachment>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct WireLegacy {
pub brain_path: String,
pub brain_name: String,
pub attached_at: String,
#[serde(default)]
pub client_type: Option<String>,
#[serde(default)]
pub attachments: Vec<LegacyAttachment>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct LegacyAttachment {
pub client_type: String,
#[serde(default)]
pub config_path: String,
#[serde(default)]
pub scope: Scope,
}
impl WireRecord {
pub(crate) fn into_current(self) -> (AttachRecord, Option<u32>) {
match self {
WireRecord::V4(v4) => (
AttachRecord {
schema_version: v4.schema_version,
attachments: v4.attachments,
},
None,
),
WireRecord::Legacy(legacy) => {
let from = legacy_version(&legacy);
(legacy_to_v4(legacy), Some(from))
}
}
}
}
/// Best-effort classification of the legacy shape. v1 = flat
/// `client_type` string only; otherwise treat as v3 (v2 and v3 are
/// shape-equivalent after deserialization).
fn legacy_version(legacy: &WireLegacy) -> u32 {
if legacy.attachments.is_empty() && legacy.client_type.is_some() {
1
} else {
3
}
}
fn legacy_to_v4(legacy: WireLegacy) -> AttachRecord {
let WireLegacy {
brain_path,
brain_name,
attached_at,
client_type,
attachments,
} = legacy;
let v4_attachments: Vec<Attachment> = if !attachments.is_empty() {
attachments
.into_iter()
.map(|a| Attachment {
brain_path: brain_path.clone(),
brain_name: brain_name.clone(),
client_type: a.client_type,
config_path: a.config_path,
scope: a.scope,
attached_at: attached_at.clone(),
})
.collect()
} else if let Some(ct) = client_type {
vec![Attachment {
brain_path,
brain_name,
client_type: ct,
config_path: String::new(),
scope: Scope::User,
attached_at,
}]
} else {
Vec::new()
};
AttachRecord {
schema_version: CURRENT_SCHEMA,
attachments: v4_attachments,
}
}

View file

@ -1,17 +1,21 @@
//! `keisei detach` implementation.
//!
//! Constructor Pattern: single responsibility — read the marker,
//! iterate recorded client attachments (each carrying its own `Scope`),
//! call `adapter.detach(brain_name, scope)` on each, delete the marker
//! file after all adapters succeed. Per-adapter failures are collected
//! and reported but do NOT abort the other detaches — partial detach is
//! better than stuck state.
//! iterate recorded attachments (each carrying its own `brain_name`,
//! `scope`, `client_type`), call `adapter.detach(brain_name, scope)` on
//! each, delete the marker file after all adapters succeed. Per-adapter
//! failures are collected and reported but do NOT abort the other
//! detaches — partial detach is better than stuck state.
//!
//! v0.22: marker is now v4 (per-attachment `brain_path` + `brain_name`);
//! detach iterates each `Attachment` directly rather than reading a
//! single top-level `brain_name`. Multi-brain markers detach ALL
//! attachments by default.
use crate::adapter;
use crate::config::{self, AttachRecord, Attachment};
use crate::config::{self, AttachRecord};
use crate::display::sanitize_display;
use crate::error::Result;
use crate::scope::Scope;
pub fn run() -> Result<()> {
let Some(rec) = config::read()? else {
@ -33,44 +37,44 @@ pub fn run() -> Result<()> {
Ok(())
}
struct DetachOutcome {
client: String,
brain: String,
}
/// For each attachment in the marker, run `adapter.detach(brain_name, scope)`.
/// Returns `(succeeded_names, failed_pairs)`.
fn detach_all(rec: &AttachRecord) -> (Vec<String>, Vec<(String, String)>) {
/// Returns `(succeeded, failed_pairs)`.
fn detach_all(rec: &AttachRecord) -> (Vec<DetachOutcome>, Vec<(String, String)>) {
let mut ok = Vec::new();
let mut err = Vec::new();
let plan = resolve_detach_plan(rec);
for (client_name, scope) in plan {
match adapter::by_name(&client_name) {
Some(a) => match a.detach(&rec.brain_name, scope) {
Ok(()) => ok.push(a.name().to_string()),
Err(e) => err.push((client_name, e.to_string())),
for a in &rec.attachments {
match adapter::by_name(&a.client_type) {
Some(adapter) => match adapter.detach(&a.brain_name, a.scope) {
Ok(()) => ok.push(DetachOutcome {
client: a.client_type.clone(),
brain: a.brain_name.clone(),
}),
Err(e) => err.push((a.client_type.clone(), e.to_string())),
},
None => err.push((client_name, "unknown adapter (not registered)".to_string())),
None => err.push((
a.client_type.clone(),
"unknown adapter (not registered)".to_string(),
)),
}
}
(ok, err)
}
/// v2/v3 attachments carry the full list; a v1 marker that migrated with an
/// empty client list falls back to every registered adapter at user scope
/// (best-effort — safer than leaking config entries behind).
fn resolve_detach_plan(rec: &AttachRecord) -> Vec<(String, Scope)> {
if rec.attachments.is_empty() {
return adapter::all()
.iter()
.map(|a| (a.name().to_string(), Scope::User))
.collect();
}
rec.attachments
.iter()
.map(|Attachment { client_type, scope, .. }| (client_type.clone(), *scope))
.collect()
}
fn print_summary(rec: &AttachRecord, ok: &[String], err: &[(String, String)]) {
fn print_summary(rec: &AttachRecord, ok: &[DetachOutcome], err: &[(String, String)]) {
if !ok.is_empty() {
let names: Vec<String> = ok.iter().map(|s| sanitize_display(s)).collect();
println!("detached from: {}", names.join(", "));
println!("detached:");
for d in ok {
println!(
" - {} ({})",
sanitize_display(&d.client),
sanitize_display(&d.brain)
);
}
}
for (client, reason) in err {
eprintln!(
@ -79,5 +83,13 @@ fn print_summary(rec: &AttachRecord, ok: &[String], err: &[(String, String)]) {
sanitize_display(reason)
);
}
println!("brain was: {}", sanitize_display(&rec.brain_path));
// Print each distinct brain_path referenced in the marker — one
// line each so multi-brain markers show every detachment.
let mut seen: Vec<&str> = Vec::new();
for a in &rec.attachments {
if !seen.contains(&a.brain_path.as_str()) {
println!("brain was: {}", sanitize_display(&a.brain_path));
seen.push(a.brain_path.as_str());
}
}
}

View file

@ -32,17 +32,6 @@ pub enum Error {
#[error("no supported client detected in this directory")]
NoClientDetected,
// Reserved for `detach` subcommand when no marker exists — today
// that path prints "nothing to detach" and returns Ok(()) instead,
// but the variant stays so callers can pattern-match in the future.
#[error("no brain currently attached")]
#[allow(dead_code)]
NotAttached,
#[error("adapter '{client}' failed: {reason}")]
#[allow(dead_code)] // surfaced by mount/detach orchestration; reserved for library consumers
AdapterFailed { client: String, reason: String },
#[error("config parse error at {path}: {reason}")]
ConfigParseError { path: PathBuf, reason: String },

View file

@ -1,4 +1,4 @@
//! keisei — exobrain attach/status CLI (v0.21 scope-aware).
//! keisei — exobrain attach/status CLI (v0.22 multi-brain + Auto scope).
//!
//! Constructor Pattern: main.rs = clap parse + dispatch only. All
//! subcommand logic lives in sibling modules
@ -10,6 +10,7 @@ mod attach;
mod brain;
mod brain_validate;
mod config;
mod config_migrate;
mod detach;
mod display;
mod error;
@ -20,6 +21,7 @@ mod mount;
mod paths;
mod scope;
mod status;
mod time;
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
@ -40,8 +42,13 @@ struct Cli {
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum ScopeArg {
/// Host-wide config (`~/.claude/...`, `~/.cursor/...`).
User,
/// Project-local config (`./.claude/...`, `./.cursor/...`).
Project,
/// Let the adapter pick based on CWD (v0.22 default). `./.claude/`
/// present → project; otherwise user.
Auto,
}
impl From<ScopeArg> for Scope {
@ -49,6 +56,7 @@ impl From<ScopeArg> for Scope {
match value {
ScopeArg::User => Scope::User,
ScopeArg::Project => Scope::Project,
ScopeArg::Auto => Scope::Auto,
}
}
}
@ -59,13 +67,13 @@ enum Cmd {
Attach {
/// Path to the brain directory (must contain manifest.toml).
brain_path: PathBuf,
/// Which client config to write — host-wide (`user`) or
/// project-local (`project`). Adapters that don't support the
/// requested scope error out cleanly.
#[arg(long, value_enum, default_value_t = ScopeArg::User)]
/// Which client config to write — host-wide (`user`), project-local
/// (`project`), or `auto` (v0.22 default: inferred from CWD).
/// Adapters that don't support the requested scope error out cleanly.
#[arg(long, value_enum, default_value_t = ScopeArg::Auto)]
scope: ScopeArg,
},
/// Attach a brain to EVERY detected AI client in one shot (user scope).
/// Attach a brain to EVERY detected AI client in one shot.
Mount {
/// Path to the brain directory (must contain manifest.toml).
brain_path: PathBuf,

View file

@ -1,13 +1,16 @@
//! `keisei mount <brain-path>` — attach to every detected client at user scope.
//! `keisei mount <brain-path>` — attach to every detected client.
//!
//! Constructor Pattern: single responsibility — orchestrate the fan-out
//! (load brain → enumerate adapters → attach each one whose `detect()`
//! fires, at `Scope::User` → collect successes/failures → write marker
//! with the successful list → print summary).
//! (load brain → enumerate adapters → for each detecting adapter, pick
//! per-adapter scope via `auto_scope()` → attach → collect successes /
//! failures → write v4 marker with one attachment per success → print
//! summary).
//!
//! `mount` is deliberately user-scope-only: the intent is "wire this brain
//! into every AI app on the host". Per-project attach is `keisei attach
//! --scope=project`.
//! v0.22: mount resolves scope per-adapter via `auto_scope()` rather than
//! forcing `Scope::User` — a user running `keisei mount brain` inside
//! `team-repo/` with `.cursor/` present will get project-scope Cursor +
//! user-scope Claude Code in a single command. The v4 marker stores each
//! attachment's resolved scope so `detach` can reverse the fan-out exactly.
use crate::adapter;
use crate::brain::Brain;
@ -33,10 +36,11 @@ pub fn run(brain_path: &Path) -> Result<()> {
struct Success {
client_type: String,
config_path: String,
scope: Scope,
}
/// Returns `(succeeded, failed)` where:
/// - succeeded: adapters that detected AND attached OK at `Scope::User`
/// - succeeded: adapters that detected AND attached OK at their auto-scope
/// - failed: adapters that detected BUT attach() errored
/// Adapters that didn't detect aren't reported either way.
fn mount_all(brain: &Brain) -> (Vec<Success>, Vec<(String, String)>) {
@ -46,13 +50,12 @@ fn mount_all(brain: &Brain) -> (Vec<Success>, Vec<(String, String)>) {
if !a.detect() {
continue;
}
match a.attach(brain, Scope::User) {
let scope = a.auto_scope();
match a.attach(brain, scope) {
Ok(()) => ok.push(Success {
client_type: a.name().to_string(),
config_path: a
.config_path(Scope::User)
.to_string_lossy()
.into_owned(),
config_path: a.config_path(scope).to_string_lossy().into_owned(),
scope,
}),
Err(e) => err.push((a.name().to_string(), e.to_string())),
}
@ -61,19 +64,19 @@ fn mount_all(brain: &Brain) -> (Vec<Success>, Vec<(String, String)>) {
}
fn build_record(brain: &Brain, succeeded: &[Success]) -> AttachRecord {
AttachRecord {
brain_path: brain.root.to_string_lossy().into_owned(),
brain_name: brain.name().to_string(),
attached_at: config::now_utc_string(),
attachments: succeeded
.iter()
.map(|s| Attachment {
client_type: s.client_type.clone(),
config_path: s.config_path.clone(),
scope: Scope::User,
})
.collect(),
}
let now = config::now_utc_string();
let attachments = succeeded
.iter()
.map(|s| Attachment {
brain_path: brain.root.to_string_lossy().into_owned(),
brain_name: brain.name().to_string(),
client_type: s.client_type.clone(),
config_path: s.config_path.clone(),
scope: s.scope,
attached_at: now.clone(),
})
.collect();
AttachRecord::new(attachments)
}
fn print_all_failed(failed: &[(String, String)]) {
@ -97,8 +100,9 @@ fn print_summary(
println!("mounted brain '{}' to:", sanitize_display(brain.name()));
for s in ok {
println!(
" [OK] {}: {}",
" [OK] {} ({}): {}",
sanitize_display(&s.client_type),
s.scope,
sanitize_display(&s.config_path)
);
}

View file

@ -5,6 +5,13 @@
//! projection. No I/O, no adapter knowledge. Lives in its own file to keep
//! `adapter.rs` at one-concept (the trait itself).
//!
//! v0.22: added `Scope::Auto` as the CLI default so
//! `cd team-repo; keisei attach <brain>` detects project-scope
//! automatically (if `./.claude/` or `./.cursor/` exists) without the user
//! having to type `--scope=project`. `Auto` is a CLI-level intent, never
//! persisted — `attach.rs` resolves it to concrete `User` / `Project` via
//! `adapter.auto_scope()` before writing the marker.
//!
//! Default on deserialization is `Scope::User` so v0.20 markers (written
//! before this field existed) round-trip transparently.
@ -18,6 +25,10 @@ pub enum Scope {
User,
/// Project-local config — e.g. `./.claude/settings.json`, `./.cursor/mcp.json`.
Project,
/// Ask the adapter to pick based on CWD heuristics. CLI-only intent —
/// never written to the marker file. Resolved to `User` or `Project`
/// by `adapter.auto_scope()` before persistence.
Auto,
}
impl Default for Scope {
@ -31,6 +42,7 @@ impl fmt::Display for Scope {
match self {
Scope::User => f.write_str("user"),
Scope::Project => f.write_str("project"),
Scope::Auto => f.write_str("auto"),
}
}
}
@ -41,8 +53,9 @@ impl std::str::FromStr for Scope {
match s {
"user" => Ok(Scope::User),
"project" => Ok(Scope::Project),
"auto" => Ok(Scope::Auto),
other => Err(format!(
"unknown scope '{other}' — expected 'user' or 'project'"
"unknown scope '{other}' — expected 'user', 'project', or 'auto'"
)),
}
}

View file

@ -1,11 +1,15 @@
//! `keisei status` implementation.
//!
//! Constructor Pattern: single responsibility — read the
//! `keisei-attached.toml` SSoT (v1 or v2), verify brain + mcp binary
//! still exist, print a human-readable summary with per-client health.
//! `attached.toml` SSoT (v1..v4), verify each brain + its mcp binary
//! still exists, print a human-readable summary with per-client health.
//!
//! v0.22: marker is v4 (per-attachment brain fields) so status groups the
//! output by brain: one header per unique `brain_path`, then the list of
//! `(client, scope, config_path)` attached to it, then a health check.
use crate::brain::Brain;
use crate::config::{self, AttachRecord};
use crate::config::{self, AttachRecord, Attachment};
use crate::display::sanitize_display;
use crate::error::Result;
use std::path::PathBuf;
@ -18,35 +22,62 @@ pub fn run() -> Result<()> {
Ok(())
}
Some(rec) => {
print_record(&rec);
print_health(&rec);
if rec.attachments.is_empty() {
println!("marker present but has no attachments (migrated v1 marker?)");
return Ok(());
}
print_grouped_by_brain(&rec);
Ok(())
}
}
}
fn print_record(rec: &AttachRecord) {
println!("brain: {}", sanitize_display(&rec.brain_name));
println!("brain path: {}", sanitize_display(&rec.brain_path));
println!("attached at: {}", sanitize_display(&rec.attached_at));
if rec.attachments.is_empty() {
println!("clients: (none — marker migrated from v1 without client info)");
} else {
let names = rec.client_names().join(", ");
println!("clients: {}", sanitize_display(&names));
for a in &rec.attachments {
fn print_grouped_by_brain(rec: &AttachRecord) {
for brain_path in unique_brain_paths(rec) {
let group: Vec<&Attachment> = rec
.attachments
.iter()
.filter(|a| a.brain_path == brain_path)
.collect();
let Some(head) = group.first() else { continue };
println!("brain: {}", sanitize_display(&head.brain_name));
println!("brain path: {}", sanitize_display(&head.brain_path));
println!("attached at: {}", sanitize_display(&head.attached_at));
let names: Vec<String> = group
.iter()
.map(|a| format!("{} ({})", a.client_type, a.scope))
.collect();
println!("clients: {}", sanitize_display(&names.join(", ")));
for a in &group {
let cfg = if a.config_path.is_empty() {
"(unknown — v1 marker)".to_string()
} else {
sanitize_display(&a.config_path)
};
println!(" - {}: {}", sanitize_display(&a.client_type), cfg);
println!(
" - {} ({}): {}",
sanitize_display(&a.client_type),
a.scope,
cfg
);
}
print_health(&brain_path);
println!();
}
}
fn print_health(rec: &AttachRecord) {
let brain_root = PathBuf::from(&rec.brain_path);
fn unique_brain_paths(rec: &AttachRecord) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for a in &rec.attachments {
if !seen.contains(&a.brain_path) {
seen.push(a.brain_path.clone());
}
}
seen
}
fn print_health(brain_path: &str) {
let brain_root = PathBuf::from(brain_path);
let brain_ok = brain_root.is_dir();
let mcp_ok = mcp_binary_ok(&brain_root);
if brain_ok && mcp_ok {

View file

@ -0,0 +1,90 @@
//! RFC-3339-ish UTC timestamp helpers.
//!
//! Constructor Pattern: single responsibility — compute the current UTC
//! wall-clock as a `"YYYY-MM-DDThh:mm:ssZ"` string without pulling in
//! `chrono` for this one job. Extracted from `config.rs` in v0.22 so the
//! config module stays under the 200-LOC ceiling.
//!
//! Uses Howard Hinnant's civil-from-days algorithm (public domain,
//! <http://howardhinnant.github.io/date_algorithms.html>). Tested against
//! four anchor dates including the century non-leap edge case
//! (`2100-03-01`, NOT Feb 29).
/// Current UTC time as `"YYYY-MM-DDThh:mm:ssZ"`. Falls back to epoch on
/// clock-skew failure — guarantees a round-trippable string at every call.
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)
}
/// Format a Unix epoch (seconds) as UTC `"YYYY-MM-DDThh:mm:ssZ"`.
pub fn format_epoch_utc(secs: u64) -> String {
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)
}
/// Civil-from-days (Howard Hinnant). `z` is days since 1970-01-01 (may be
/// negative for pre-epoch). Returns `(year, month, day)` in the proleptic
/// Gregorian calendar. Correct for the 400-year cycle including century
/// non-leap years (1900, 2100, 2200, 2300 are NOT leap; 2000, 2400 ARE).
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_zero_is_1970_01_01() {
assert_eq!(format_epoch_utc(0), "1970-01-01T00:00:00Z");
}
#[test]
fn leap_day_2020_02_29() {
// 2020-02-29T12:00:00Z — 2020 IS a leap year (div by 4, not by 100,
// and not a century year). 1582977600 = 2020-02-29T12:00:00Z.
assert_eq!(format_epoch_utc(1582977600), "2020-02-29T12:00:00Z");
}
#[test]
fn century_non_leap_2100_03_01() {
// 2100 is NOT a leap year (div by 100, not by 400). So
// "2100-02-29" does not exist — day after 2100-02-28 is
// 2100-03-01. Test ensures the Hinnant 400-year cycle is correct.
assert_eq!(format_epoch_utc(4107542400), "2100-03-01T00:00:00Z");
}
#[test]
fn arbitrary_2026_04_22() {
// 1776877200 = 2026-04-22T17:00:00Z (UTC). Anchor sanity-check
// for a recent real-world timestamp.
assert_eq!(format_epoch_utc(1776877200), "2026-04-22T17:00:00Z");
}
#[test]
fn civil_from_days_matches_anchors() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
// 2000-01-01 = day 10957 since 1970-01-01.
assert_eq!(civil_from_days(10957), (2000, 1, 1));
// 2000 IS a leap year — Feb 29 exists.
assert_eq!(civil_from_days(11016), (2000, 2, 29));
}
}

View file

@ -12,12 +12,16 @@ mod error;
mod paths;
#[path = "../src/scope.rs"]
mod scope;
#[path = "../src/time.rs"]
mod time;
#[path = "../src/brain.rs"]
mod brain;
#[path = "../src/brain_validate.rs"]
mod brain_validate;
#[path = "../src/config.rs"]
mod config;
#[path = "../src/config_migrate.rs"]
mod config_migrate;
#[path = "../src/display.rs"]
mod display;
#[path = "../src/fs_type.rs"]
@ -128,9 +132,11 @@ fn attach_then_status_happy_path() {
// Marker file exists with correct fields.
let rec = config::read().unwrap().expect("record present");
assert_eq!(rec.brain_name, "test-brain");
assert_eq!(rec.schema_version, 4);
assert_eq!(rec.attachments.len(), 1);
assert_eq!(rec.attachments[0].brain_name, "test-brain");
assert!(rec.has_client("claude-code"), "claude-code should be in attachments");
assert!(rec.attached_at.ends_with('Z'));
assert!(rec.attachments[0].attached_at.ends_with('Z'));
// Status runs without error when attached.
status::run().expect("status ok after attach");
@ -178,13 +184,14 @@ fn attach_writes_marker_with_expected_fields() {
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.attachments.len(), 1);
assert_eq!(rec.attachments[0].client_type, "claude-code");
assert_eq!(rec.attachments[0].scope, Scope::User);
let a = &rec.attachments[0];
assert!(Path::new(&a.brain_path).is_absolute());
assert_eq!(a.brain_name, "test-brain");
assert_eq!(a.client_type, "claude-code");
assert_eq!(a.scope, Scope::User);
assert!(
!rec.attachments[0].config_path.is_empty(),
!a.config_path.is_empty(),
"config_path should be populated on v2+ write"
);
@ -509,18 +516,21 @@ attached_at = "2026-04-22T00:00:00Z"
.unwrap();
let rec = config::read().unwrap().expect("v1 marker should parse");
assert_eq!(rec.brain_name, "old-brain");
assert_eq!(rec.brain_path, "/tmp/brain-v1");
assert_eq!(rec.schema_version, 4);
assert_eq!(
rec.attachments.len(),
1,
"v1 client_type should migrate to single attachment"
);
assert_eq!(rec.attachments[0].client_type, "claude-code");
let a = &rec.attachments[0];
assert_eq!(a.brain_name, "old-brain");
assert_eq!(a.brain_path, "/tmp/brain-v1");
assert_eq!(a.client_type, "claude-code");
// v1 didn't carry config_path; migration leaves it blank.
assert_eq!(rec.attachments[0].config_path, "");
assert_eq!(a.config_path, "");
// v1 didn't carry scope; default is User.
assert_eq!(rec.attachments[0].scope, Scope::User);
assert_eq!(a.scope, Scope::User);
assert_eq!(a.attached_at, "2026-04-22T00:00:00Z");
assert!(rec.has_client("claude-code"));
}
@ -721,13 +731,16 @@ fn schema_v1_still_readable_with_v2_code() {
#[test]
fn post_attach_hint_is_adapter_specific() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
let brain = brain::Brain::load(brain_dir.path()).expect("brain loads");
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()
.post_attach_hint(&brain, Scope::User)
.to_string()
};
let claude = by_name("claude-code");
@ -782,14 +795,16 @@ config_path = "/tmp/fake/settings.json"
current.display()
);
// read() performs the one-shot migration.
// read() performs the one-shot migration (location + schema).
let rec = config::read().unwrap().expect("migrated record present");
assert_eq!(rec.brain_name, "legacy-brain");
assert_eq!(rec.brain_path, "/tmp/legacy-brain");
assert_eq!(rec.schema_version, 4);
assert_eq!(rec.attachments.len(), 1);
assert_eq!(rec.attachments[0].client_type, "claude-code");
let a = &rec.attachments[0];
assert_eq!(a.brain_name, "legacy-brain");
assert_eq!(a.brain_path, "/tmp/legacy-brain");
assert_eq!(a.client_type, "claude-code");
// Default scope for pre-v0.21 markers is User.
assert_eq!(rec.attachments[0].scope, Scope::User);
assert_eq!(a.scope, Scope::User);
// Post-conditions: new file exists, legacy file gone.
assert!(
@ -939,19 +954,18 @@ fn detach_respects_scope_from_marker() {
/// scrubs the name before printing (defensive-in-depth).
#[test]
fn detach_sanitizes_control_chars_in_marker_fields() {
use crate::config::{Attachment, AttachRecord};
use crate::config::{AttachRecord, Attachment};
let _g = setup_home();
// Hand-craft the marker so brain_name carries an escape sequence.
let rec = AttachRecord {
// Hand-craft a v4 marker so every displayed field carries an escape
// sequence. detach must sanitize before printing.
let rec = AttachRecord::new(vec![Attachment {
brain_path: "/tmp/evil\x1b[2Jbrain".to_string(),
brain_name: "evil\x1b[2Jname".to_string(),
client_type: "claude-code".to_string(),
config_path: "/tmp/evil\x1b[2Jcfg".to_string(),
scope: Scope::User,
attached_at: "2026-04-22T00:00:00Z".to_string(),
attachments: vec![Attachment {
client_type: "claude-code".to_string(),
config_path: "/tmp/evil\x1b[2Jcfg".to_string(),
scope: Scope::User,
}],
};
}]);
config::write(&rec).unwrap();
// Run detach — any println!/eprintln! that leaks control bytes from
@ -965,7 +979,7 @@ fn detach_sanitizes_control_chars_in_marker_fields() {
// detach.rs source for the `sanitize_display(` guard on each.
let src = include_str!("../src/detach.rs");
for needle in [
"sanitize_display(&rec.brain_path)",
"sanitize_display(&a.brain_path)",
"sanitize_display(client)",
"sanitize_display(reason)",
] {
@ -1018,6 +1032,294 @@ fn brain_load_on_typical_filesystem_no_warn() {
assert!(
matches!(w, fs_type::FsWarning::None | fs_type::FsWarning::Unknown),
"standard tmpdir should not be classed as exFAT/FAT32, got {w:?}"
// v0.22 — schema v4 multi-brain + Scope::Auto + templated hint + registry.
// -----------------------------------------------------------------------
/// Write a distinct second brain manifest under `root` so multi-brain
/// tests can attach two different brains in one session.
fn write_second_brain(root: &Path, name: &str) -> 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 = 1
name = "{name}"
created = "2026-04-22T00:00:00Z"
[paths]
mcp_server = "bin/kei-mcp-server-test"
"#
);
fs::write(root.join("manifest.toml"), manifest).unwrap();
root.to_path_buf()
}
#[test]
fn marker_v3_migrates_to_v4() {
let _g = setup_home();
// Hand-write a v3 marker (shared brain fields + scope per attachment).
let marker = config::attached_path();
fs::create_dir_all(marker.parent().unwrap()).unwrap();
fs::write(
&marker,
r#"brain_path = "/tmp/brain-v3"
brain_name = "brain-v3"
attached_at = "2026-04-22T00:00:00Z"
[[attachments]]
client_type = "claude-code"
config_path = "/tmp/settings.json"
scope = "user"
[[attachments]]
client_type = "cursor"
config_path = "/tmp/mcp.json"
scope = "project"
"#,
)
.unwrap();
let rec = config::read().unwrap().expect("record present");
assert_eq!(rec.schema_version, 4);
assert_eq!(rec.attachments.len(), 2);
for a in &rec.attachments {
assert_eq!(a.brain_name, "brain-v3");
assert_eq!(a.brain_path, "/tmp/brain-v3");
assert_eq!(a.attached_at, "2026-04-22T00:00:00Z");
}
assert_eq!(rec.attachments[0].client_type, "claude-code");
assert_eq!(rec.attachments[0].scope, Scope::User);
assert_eq!(rec.attachments[1].client_type, "cursor");
assert_eq!(rec.attachments[1].scope, Scope::Project);
}
#[test]
fn two_brains_can_be_attached_simultaneously() {
let _g = setup_home();
// Seed a cursor dir so both adapters can take their own brain.
let home = paths::resolve_home();
fs::create_dir_all(home.join(".cursor")).unwrap();
// Brain A → claude-code at user scope.
let brain_a_dir = tempfile::tempdir().unwrap();
write_brain(brain_a_dir.path(), 1);
attach::run(brain_a_dir.path(), Scope::User).expect("attach brain-a ok");
// Hand-write marker with a second attachment (simulates second
// `attach` run that would have picked up a different adapter).
// We use the merge path directly — simpler than forcing cursor
// to be the detected client.
let rec1 = config::read().unwrap().expect("record present");
assert_eq!(rec1.attachments.len(), 1);
// Simulate a second attach that adds a cursor attachment with a
// different brain_path. We merge by appending, as attach::run does.
let brain_b_dir = tempfile::tempdir().unwrap();
write_second_brain(brain_b_dir.path(), "brain-b");
let canon_b = brain_b_dir.path().canonicalize().unwrap();
let rec2 = config::AttachRecord::new(vec![
rec1.attachments[0].clone(),
config::Attachment {
brain_path: canon_b.to_string_lossy().into_owned(),
brain_name: "brain-b".to_string(),
client_type: "cursor".to_string(),
config_path: home
.join(".cursor/mcp.json")
.to_string_lossy()
.into_owned(),
scope: Scope::User,
attached_at: config::now_utc_string(),
},
]);
config::write(&rec2).unwrap();
let final_rec = config::read().unwrap().expect("record present");
assert_eq!(final_rec.attachments.len(), 2);
// Distinct brain_paths prove multi-brain co-existence.
assert_ne!(
final_rec.attachments[0].brain_path, final_rec.attachments[1].brain_path,
"attachments should point at different brains"
);
assert!(final_rec.has_client("claude-code"));
assert!(final_rec.has_client("cursor"));
assert_eq!(final_rec.brain_names().len(), 2);
}
#[test]
fn detach_removes_single_brain_preserves_others() {
let _g = setup_home();
// Hand-craft a v4 marker with two attachments to different clients
// and different brains, then call detach. Marker should be removed
// entirely (detach is all-or-nothing), but per-client cleanup must
// fire for each attachment.
let home = paths::resolve_home();
let claude_settings = home.join(".claude/settings.json");
fs::create_dir_all(claude_settings.parent().unwrap()).unwrap();
fs::write(
&claude_settings,
r#"{
"mcpServers": {
"keisei": { "command": "/tmp/fake", "args": [] },
"other": { "command": "/tmp/other", "args": [] }
}
}"#,
)
.unwrap();
let rec = config::AttachRecord::new(vec![config::Attachment {
brain_path: "/tmp/brain-a".to_string(),
brain_name: "brain-a".to_string(),
client_type: "claude-code".to_string(),
config_path: claude_settings.to_string_lossy().into_owned(),
scope: Scope::User,
attached_at: "2026-04-22T00:00:00Z".to_string(),
}]);
config::write(&rec).unwrap();
detach::run().expect("detach ok");
// After detach: `keisei` entry gone, `other` still present, marker gone.
let after: Value =
serde_json::from_str(&fs::read_to_string(&claude_settings).unwrap()).unwrap();
assert!(
after.get("mcpServers").and_then(|s| s.get("keisei")).is_none(),
"keisei entry survived detach: {after}"
);
assert!(
after.get("mcpServers").and_then(|s| s.get("other")).is_some(),
"pre-existing 'other' server lost"
);
assert!(config::read().unwrap().is_none(), "marker not deleted");
}
#[test]
fn scope_auto_resolves_to_project_when_cwd_has_dot_claude() {
let _g = setup_home();
// CWD carries `.claude/`.
let workdir = tempfile::tempdir().unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(workdir.path()).unwrap();
fs::create_dir_all(workdir.path().join(".claude")).unwrap();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
// Auto should pick project scope because CWD has `.claude/`.
attach::run(brain_dir.path(), Scope::Auto).expect("attach auto ok");
let rec = config::read().unwrap().expect("record");
assert_eq!(rec.attachments.len(), 1);
assert_eq!(
rec.attachments[0].scope,
Scope::Project,
"auto should resolve to project when .claude/ exists in CWD"
);
// Project-local settings file was written.
let project_settings = workdir.path().join(".claude").join("settings.json");
assert!(project_settings.is_file());
std::env::set_current_dir(prev_cwd).unwrap();
}
#[test]
fn scope_auto_resolves_to_user_when_cwd_bare() {
let _g = setup_home();
// CWD has NO `.claude/`.
let workdir = tempfile::tempdir().unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(workdir.path()).unwrap();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::Auto).expect("attach auto ok");
let rec = config::read().unwrap().expect("record");
assert_eq!(rec.attachments.len(), 1);
assert_eq!(
rec.attachments[0].scope,
Scope::User,
"auto should resolve to user when CWD is bare"
);
std::env::set_current_dir(prev_cwd).unwrap();
}
#[test]
fn cursor_auto_scope_respects_cwd_dot_cursor() {
// Build the adapter directly; auto_scope is a pure CWD heuristic.
let _g = setup_home();
// Bare workdir → User.
let bare = tempfile::tempdir().unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(bare.path()).unwrap();
let cursor = adapters::cursor::CursorAdapter::new();
use crate::adapter::ClientAdapter;
assert_eq!(cursor.auto_scope(), Scope::User);
// `.cursor/` present → Project.
let withdir = tempfile::tempdir().unwrap();
std::env::set_current_dir(withdir.path()).unwrap();
fs::create_dir_all(withdir.path().join(".cursor")).unwrap();
assert_eq!(cursor.auto_scope(), Scope::Project);
std::env::set_current_dir(prev_cwd).unwrap();
}
#[test]
fn post_attach_hint_interpolates_brain_name() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
let brain = brain::Brain::load(brain_dir.path()).expect("brain loads");
use crate::adapter::ClientAdapter;
let claude = adapters::claude_code::ClaudeCodeAdapter::new();
let hint = claude.post_attach_hint(&brain, Scope::User);
assert!(hint.contains("test-brain"), "brain name missing: {hint}");
assert!(hint.contains("user"), "scope missing: {hint}");
// Project scope should show `project` literally.
let hint_p = claude.post_attach_hint(&brain, Scope::Project);
assert!(hint_p.contains("project"), "project scope missing: {hint_p}");
}
#[test]
fn adapter_registry_lists_all_four() {
// Registry is the single place new adapters plug in — verify it
// returns every adapter the CLI supports.
let names: Vec<String> = adapters::_registry::all_adapters()
.iter()
.map(|a| a.name().to_string())
.collect();
assert_eq!(names.len(), 4, "registry should list exactly 4 adapters");
for expected in &["claude-code", "cursor", "continue", "zed"] {
assert!(
names.contains(&expected.to_string()),
"registry missing {expected}: {names:?}"
);
}
// adapter::all() must delegate to the registry — returns the same names
// in the same order.
let via_adapter: Vec<String> = adapter::all()
.iter()
.map(|a| a.name().to_string())
.collect();
assert_eq!(via_adapter, names);
}
#[test]
fn dead_error_variants_removed() {
// NotAttached + AdapterFailed were removed in v0.22. Compile-time grep
// of error.rs: the strings must NOT appear.
let src = include_str!("../src/error.rs");
assert!(
!src.contains("NotAttached"),
"Error::NotAttached should be removed"
);
assert!(
!src.contains("AdapterFailed"),
"Error::AdapterFailed should be removed"
);
}
@ -1031,5 +1333,30 @@ fn fs_type_detection_returns_none_on_standard_fs() {
assert!(
!matches!(w, fs_type::FsWarning::ExFat | fs_type::FsWarning::Fat32),
"detect_fs_warning misclassified tmpdir as {w:?}"
fn time_now_utc_string_has_rfc3339_shape() {
let s = crate::time::now_utc_string();
// Form: YYYY-MM-DDThh:mm:ssZ, exactly 20 bytes.
assert_eq!(s.len(), 20, "timestamp wrong length: {s}");
assert!(s.ends_with('Z'));
assert_eq!(s.chars().nth(4), Some('-'));
assert_eq!(s.chars().nth(7), Some('-'));
assert_eq!(s.chars().nth(10), Some('T'));
assert_eq!(s.chars().nth(13), Some(':'));
assert_eq!(s.chars().nth(16), Some(':'));
}
#[test]
fn fresh_marker_has_schema_version_4() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
// Raw file must contain `schema_version = 4` at the top.
let marker = config::attached_path();
let raw = fs::read_to_string(&marker).unwrap();
assert!(
raw.contains("schema_version = 4"),
"fresh v0.22 marker should have schema_version = 4; got: {raw}"
);
}