From 4bbc95fd7ce3507f0179acf0d05c05da4f85db8b Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Wed, 22 Apr 2026 21:00:13 +0800 Subject: [PATCH] feat(v0.22): keisei schema v4 + Scope::Auto + templated hint + registry (Track A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' 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 '' 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) --- CHANGELOG.md | 22 + _primitives/_rust/keisei/src/adapter.rs | 52 ++- .../_rust/keisei/src/adapters/_registry.rs | 32 ++ .../_rust/keisei/src/adapters/claude_code.rs | 26 +- .../keisei/src/adapters/continue_adapter.rs | 7 +- .../_rust/keisei/src/adapters/cursor.rs | 23 +- _primitives/_rust/keisei/src/adapters/mod.rs | 3 + _primitives/_rust/keisei/src/adapters/zed.rs | 7 +- _primitives/_rust/keisei/src/attach.rs | 63 ++- _primitives/_rust/keisei/src/config.rs | 194 ++++----- .../_rust/keisei/src/config_migrate.rs | 114 +++++ _primitives/_rust/keisei/src/detach.rs | 84 ++-- _primitives/_rust/keisei/src/error.rs | 11 - _primitives/_rust/keisei/src/main.rs | 20 +- _primitives/_rust/keisei/src/mount.rs | 58 +-- _primitives/_rust/keisei/src/scope.rs | 15 +- _primitives/_rust/keisei/src/status.rs | 67 ++- _primitives/_rust/keisei/src/time.rs | 90 ++++ _primitives/_rust/keisei/tests/integration.rs | 391 ++++++++++++++++-- 19 files changed, 991 insertions(+), 288 deletions(-) create mode 100644 _primitives/_rust/keisei/src/adapters/_registry.rs create mode 100644 _primitives/_rust/keisei/src/config_migrate.rs create mode 100644 _primitives/_rust/keisei/src/time.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5bb6b..b60b44a 100644 --- a/CHANGELOG.md +++ b/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 ` 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=` (default `user`); `keisei mount` stays host-wide (`Scope::User` fan-out by design). diff --git a/_primitives/_rust/keisei/src/adapter.rs b/_primitives/_rust/keisei/src/adapter.rs index b36360c..a17bd33 100644 --- a/_primitives/_rust/keisei/src/adapter.rs +++ b/_primitives/_rust/keisei/src/adapter.rs @@ -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> { - 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` diff --git a/_primitives/_rust/keisei/src/adapters/_registry.rs b/_primitives/_rust/keisei/src/adapters/_registry.rs new file mode 100644 index 0000000..0d623dd --- /dev/null +++ b/_primitives/_rust/keisei/src/adapters/_registry.rs @@ -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/.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> { + vec![ + Box::new(ClaudeCodeAdapter::new()), + Box::new(CursorAdapter::new()), + Box::new(ContinueAdapter::new()), + Box::new(ZedAdapter::new()), + ] +} diff --git a/_primitives/_rust/keisei/src/adapters/claude_code.rs b/_primitives/_rust/keisei/src/adapters/claude_code.rs index 5a3c2a4..ef0d147 100644 --- a/_primitives/_rust/keisei/src/adapters/claude_code.rs +++ b/_primitives/_rust/keisei/src/adapters/claude_code.rs @@ -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() + ) } } diff --git a/_primitives/_rust/keisei/src/adapters/continue_adapter.rs b/_primitives/_rust/keisei/src/adapters/continue_adapter.rs index 6dc925d..d1c1743 100644 --- a/_primitives/_rust/keisei/src/adapters/continue_adapter.rs +++ b/_primitives/_rust/keisei/src/adapters/continue_adapter.rs @@ -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() + ) } } diff --git a/_primitives/_rust/keisei/src/adapters/cursor.rs b/_primitives/_rust/keisei/src/adapters/cursor.rs index cc2a2cb..bc14793 100644 --- a/_primitives/_rust/keisei/src/adapters/cursor.rs +++ b/_primitives/_rust/keisei/src/adapters/cursor.rs @@ -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() + ) } } diff --git a/_primitives/_rust/keisei/src/adapters/mod.rs b/_primitives/_rust/keisei/src/adapters/mod.rs index 45db347..7b3b1ad 100644 --- a/_primitives/_rust/keisei/src/adapters/mod.rs +++ b/_primitives/_rust/keisei/src/adapters/mod.rs @@ -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; diff --git a/_primitives/_rust/keisei/src/adapters/zed.rs b/_primitives/_rust/keisei/src/adapters/zed.rs index f131c05..1809b25 100644 --- a/_primitives/_rust/keisei/src/adapters/zed.rs +++ b/_primitives/_rust/keisei/src/adapters/zed.rs @@ -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() + ) } } diff --git a/_primitives/_rust/keisei/src/attach.rs b/_primitives/_rust/keisei/src/attach.rs index 53d90a8..2987cfd 100644 --- a/_primitives/_rust/keisei/src/attach.rs +++ b/_primitives/_rust/keisei/src/attach.rs @@ -1,10 +1,18 @@ -//! `keisei attach [--scope=]` implementation. +//! `keisei attach [--scope=]` 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 { + 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)); } diff --git a/_primitives/_rust/keisei/src/config.rs b/_primitives/_rust/keisei/src/config.rs index 475204a..79d22b7 100644 --- a/_primitives/_rust/keisei/src/config.rs +++ b/_primitives/_rust/keisei/src/config.rs @@ -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, } 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) -> 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 { 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, - #[serde(default)] - attachments: Vec, -} - -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 { + let mut out: Vec = 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 { } 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> { migrate_from_legacy()?; let path = attached_path(); @@ -166,7 +135,13 @@ pub fn read() -> Result> { } 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 { @@ -180,8 +155,6 @@ pub fn delete() -> Result { /// 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() } diff --git a/_primitives/_rust/keisei/src/config_migrate.rs b/_primitives/_rust/keisei/src/config_migrate.rs new file mode 100644 index 0000000..2056f46 --- /dev/null +++ b/_primitives/_rust/keisei/src/config_migrate.rs @@ -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, +} + +#[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, + #[serde(default)] + pub attachments: Vec, +} + +#[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) { + 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 = 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, + } +} diff --git a/_primitives/_rust/keisei/src/detach.rs b/_primitives/_rust/keisei/src/detach.rs index a3874da..a61d425 100644 --- a/_primitives/_rust/keisei/src/detach.rs +++ b/_primitives/_rust/keisei/src/detach.rs @@ -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, Vec<(String, String)>) { +/// Returns `(succeeded, failed_pairs)`. +fn detach_all(rec: &AttachRecord) -> (Vec, 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 = 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()); + } + } } diff --git a/_primitives/_rust/keisei/src/error.rs b/_primitives/_rust/keisei/src/error.rs index 6341858..93a4bfe 100644 --- a/_primitives/_rust/keisei/src/error.rs +++ b/_primitives/_rust/keisei/src/error.rs @@ -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 }, diff --git a/_primitives/_rust/keisei/src/main.rs b/_primitives/_rust/keisei/src/main.rs index e29a74a..0dda42d 100644 --- a/_primitives/_rust/keisei/src/main.rs +++ b/_primitives/_rust/keisei/src/main.rs @@ -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 for Scope { @@ -48,6 +55,7 @@ impl From 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, diff --git a/_primitives/_rust/keisei/src/mount.rs b/_primitives/_rust/keisei/src/mount.rs index 8e4738c..d48896c 100644 --- a/_primitives/_rust/keisei/src/mount.rs +++ b/_primitives/_rust/keisei/src/mount.rs @@ -1,13 +1,16 @@ -//! `keisei mount ` — attach to every detected client at user scope. +//! `keisei mount ` — 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, Vec<(String, String)>) { @@ -46,13 +50,12 @@ fn mount_all(brain: &Brain) -> (Vec, 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, 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) ); } diff --git a/_primitives/_rust/keisei/src/scope.rs b/_primitives/_rust/keisei/src/scope.rs index 2cf15df..a6c1b40 100644 --- a/_primitives/_rust/keisei/src/scope.rs +++ b/_primitives/_rust/keisei/src/scope.rs @@ -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 ` 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'" )), } } diff --git a/_primitives/_rust/keisei/src/status.rs b/_primitives/_rust/keisei/src/status.rs index 5b9a591..479f556 100644 --- a/_primitives/_rust/keisei/src/status.rs +++ b/_primitives/_rust/keisei/src/status.rs @@ -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 = 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 { + let mut seen: Vec = 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 { diff --git a/_primitives/_rust/keisei/src/time.rs b/_primitives/_rust/keisei/src/time.rs new file mode 100644 index 0000000..b6cbb84 --- /dev/null +++ b/_primitives/_rust/keisei/src/time.rs @@ -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, +//! ). 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)); + } +} diff --git a/_primitives/_rust/keisei/tests/integration.rs b/_primitives/_rust/keisei/tests/integration.rs index 29c29f8..68d8a9d 100644 --- a/_primitives/_rust/keisei/tests/integration.rs +++ b/_primitives/_rust/keisei/tests/integration.rs @@ -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 = 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 = 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}" + ); +}