feat(v0.22): keisei schema v4 + Scope::Auto + templated hint + registry (Track A)
BREAKING schema bump v3 → v4. Backward-compat via untagged serde
for v1/v2/v3 read-paths — no user-visible regression.
1. Schema v4 — multi-brain marker
AttachRecord inverted: each Attachment carries its own brain_path
+ brain_name + scope + attached_at. Enables brain-A to Claude-
Code (user scope) + brain-B to Cursor (project scope) in ONE
marker. v1/v2/v3 auto-migrate via config_migrate.rs (NEW, 114
LOC) — silent stderr notice on first v0.22 read.
2. Scope::Auto — default CLI behaviour
'keisei attach <brain>' no longer defaults User scope blindly.
New Scope::Auto resolves per-adapter via auto_scope():
claude-code: CWD/.claude/ present → Project else User
cursor: CWD/.cursor/ present → Project else User
continue: User (no project concept)
zed: User (global settings only)
'keisei mount' stays host-wide (always User fan-out).
3. Templated post_attach_hint
fn post_attach_hint(&self, brain: &Brain, scope: Scope) -> String
Each adapter interpolates brain name + scope. Example for
claude-code: 'run /help in Claude Code (user scope) — verify
'<brain-name>' is in mcpServers'.
4. Adapter registry
adapters/_registry.rs (NEW, 32 LOC) — single canonical list
of 4 adapters. adapter::all() delegates. 5th adapter = one
line change, one place.
5. Dead code cleanup
Error::NotAttached + Error::AdapterFailed removed. Grep-verified
zero references.
6. config.rs decomposition (200 LOC rule)
config.rs 224 → 197 LOC.
time.rs (NEW, 90 LOC) — now_utc_string + format_epoch_utc +
civil_from_days Howard Hinnant + 5 unit tests
(epoch-0, leap day 2020-02-29, century-non-leap 2100-03-01,
arbitrary 2026-04-22, RFC3339 shape).
config_migrate.rs (NEW, 114 LOC) — WireRecord migration.
REAL VERIFICATION:
cargo test -p keisei --release: 46 passed 0 failed
(5 time::tests + 41 integration — 30 existing adjusted to v4 + 11 new)
Tests added:
marker_v3_migrates_to_v4
two_brains_can_be_attached_simultaneously
detach_removes_single_brain_preserves_others
scope_auto_resolves_to_{project,user}_*
cursor_auto_scope_respects_cwd_dot_cursor
post_attach_hint_interpolates_brain_name
adapter_registry_lists_all_four
dead_error_variants_removed
time_now_utc_string_has_rfc3339_shape
fresh_marker_has_schema_version_4
Agent corrected 3 of the spec's epoch anchor timestamps
(1583020800 → 1582977600 for 2020-02-29T12:00:00Z, 1776870000 →
1776877200 for 2026-04-22T17:00:00Z); century-non-leap anchor
4107542400 → 2100-03-01 was already correct.
Known pre-existing: continue_adapter.rs 206 LOC (was 204; +2 for
signature widening). Out-of-scope for this track.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f33408f0d6
commit
4bbc95fd7c
19 changed files with 991 additions and 288 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -20,7 +20,29 @@ _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 (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 — schema migration notice):**
|
||||
- `config::read()` emits a one-line stderr notice (`keisei: migrated marker shape v{N} → v4 (in-memory; …)`) when it encounters a v1/v2/v3 marker. The migration is in-memory; the next attach/detach persists v4. Location migration (v0.20 legacy path → v0.21 path) retains its own notice.
|
||||
|
||||
### 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).
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
32
_primitives/_rust/keisei/src/adapters/_registry.rs
Normal file
32
_primitives/_rust/keisei/src/adapters/_registry.rs
Normal 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()),
|
||||
]
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¤t, &raw)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(¤t)?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
std::fs::set_permissions(¤t, perms)?;
|
||||
}
|
||||
apply_owner_perms(¤t)?;
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
114
_primitives/_rust/keisei/src/config_migrate.rs
Normal file
114
_primitives/_rust/keisei/src/config_migrate.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -19,6 +20,7 @@ mod mount;
|
|||
mod paths;
|
||||
mod scope;
|
||||
mod status;
|
||||
mod time;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -39,8 +41,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 {
|
||||
|
|
@ -48,6 +55,7 @@ impl From<ScopeArg> for Scope {
|
|||
match value {
|
||||
ScopeArg::User => Scope::User,
|
||||
ScopeArg::Project => Scope::Project,
|
||||
ScopeArg::Auto => Scope::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,13 +66,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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
90
_primitives/_rust/keisei/src/time.rs
Normal file
90
_primitives/_rust/keisei/src/time.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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/fsx.rs"]
|
||||
|
|
@ -126,9 +130,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");
|
||||
|
|
@ -176,13 +182,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"
|
||||
);
|
||||
|
||||
|
|
@ -507,18 +514,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"));
|
||||
}
|
||||
|
||||
|
|
@ -719,13 +729,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");
|
||||
|
|
@ -780,14 +793,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!(
|
||||
|
|
@ -937,19 +952,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
|
||||
|
|
@ -963,7 +977,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)",
|
||||
] {
|
||||
|
|
@ -993,3 +1007,324 @@ fn mount_sanitizes_control_chars_in_error_reason() {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue