feat(v0.21): keisei SSoT relocation + Scope enum (user/project)

Two architect-audit P1/P2 findings closed.

PART A — SSoT relocation
  Before: ~/.claude/keisei-attached.toml (baked Claude-Code subpath)
  After: ~/.keisei/attached.toml (client-neutral)
  config::migrate_from_legacy() runs inside config::read() — first
  call after v0.21 install reads legacy path, writes new path,
  deletes legacy, emits stderr notice.
  claude_code adapter's .claude/ subpath UNCHANGED — that's Claude
  Code's real config dir, not keisei's marker namespace.

PART B — Scope enum (architect P1)
  ClientAdapter trait gains:
    fn supported_scopes(&self) -> &[Scope] { &[Scope::User] }  // default
    fn config_path(&self, scope: Scope) -> PathBuf
    fn attach(&self, brain: &Brain, scope: Scope) -> Result<()>
    fn detach(&self, brain_name: &str, scope: Scope) -> Result<()>

  Per-adapter scope support:
    claude_code — [User, Project]  (~/.claude vs ./.claude)
    cursor      — [User, Project]  (~/.cursor vs ./.cursor)
    continue    — [User] only (Continue has no project concept)
    zed         — [User] only (Zed uses global settings)

  CLI: keisei attach <brain> --scope={user|project} (default user).
  keisei mount → always Scope::User (host-wide fan-out).
  Marker Attachment gains scope field with #[serde(default)] so
  v0.20 markers read as Scope::User (backward-compat).

  New Error::ScopeUnsupported { client, scope, supported } — blocks
  invalid combos (e.g. zed --scope=project) with clear message.

New module scope.rs (49 LOC) — Scope enum + serde + Display + FromStr.
paths.rs gains keisei_state_dir() returning $HOME/.keisei.

5 new integration tests:
  - legacy_marker_migrates_on_first_read
  - attach_with_project_scope_writes_local_config
  - attach_user_scope_still_default
  - scope_unsupported_by_adapter_errors
  - detach_respects_scope_from_marker

REAL VERIFIED cargo test -p keisei output: 28 passed; 0 failed.
cargo check -p keisei: clean.
grep /Users/denisparfionovich/ in edits: zero hits.

Constructor Pattern: scope.rs 49 LOC, paths.rs 34 LOC, largest fn
migrate_from_legacy() 22 LOC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-22 17:56:10 +08:00
parent e372c95f29
commit 81e3b58533
16 changed files with 622 additions and 126 deletions

View file

@ -21,6 +21,10 @@ _primitives/_rust/target/release/kei-changelog \
> ships must be replaced with the real commit summary before release.
### Added
- **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).
- Marker schema v3: each `[[attachments]]` entry carries `scope = "user" | "project"`. Pre-v0.21 markers without the field default to `Scope::User` silently. New error variant `Error::ScopeUnsupported { client, scope, supported }` fires when a caller asks for a scope the adapter doesn't advertise.
- **primitives (v0.20 — brain schema v2 + per-client hint):**
- Brain schema v2 with per-platform `mcp_server` dispatch — a single brain directory can now host binaries for darwin-arm64/darwin-x64/linux-x64/linux-arm64/windows-x64 and `keisei attach` picks the right one automatically. Schema v1 (single string) still accepted for backward-compat.
- `ClientAdapter::post_attach_hint()` — per-client reload instruction, no more hardcoded Claude-Code string in the orchestrator.

View file

@ -2,9 +2,13 @@
//!
//! 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/`.
//! lives in its own file under `adapters/`. `Scope` itself lives in
//! `scope.rs` (own file, own concept).
//!
//! v0.19: Claude Code + Cursor + Continue + Zed.
//! 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]`.
use crate::adapters::{
claude_code::ClaudeCodeAdapter,
@ -14,14 +18,23 @@ use crate::adapters::{
};
use crate::brain::Brain;
use crate::error::{Error, Result};
use crate::scope::Scope;
use std::path::PathBuf;
pub trait ClientAdapter {
fn name(&self) -> &str;
fn detect(&self) -> bool;
fn attach(&self, brain: &Brain) -> Result<()>;
fn detach(&self) -> Result<()>;
fn config_path(&self) -> PathBuf;
/// Which scopes this adapter can write into.
/// Default: user-only — the safe conservative choice for adapters that
/// haven't explicitly opted into project-local configs.
fn supported_scopes(&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<()>;
/// One-line instruction the CLI prints after a successful attach so
/// the user knows how to make the client see the new MCP server.
@ -32,6 +45,11 @@ pub trait ClientAdapter {
fn post_attach_hint(&self) -> &str {
"reload your AI client to pick up the new MCP server"
}
/// Helper: does this adapter support the given scope?
fn supports_scope(&self, scope: Scope) -> bool {
self.supported_scopes().contains(&scope)
}
}
/// Enumerate all adapters the binary knows about, in priority order.

View file

@ -1,6 +1,7 @@
//! Claude Code adapter — writes MCP server entry into
//! `~/.claude/settings.json`. Config shape merges under
//! `mcpServers.keisei` so we never clobber unrelated entries.
//! `~/.claude/settings.json` (user scope) or `./.claude/settings.json`
//! (project scope). Config shape merges under `mcpServers.keisei` so we
//! never clobber unrelated entries.
//!
//! Detection: `$CWD/.claude/settings.json` exists OR
//! `$KEISEI_HOME/.claude` (or `$HOME/.claude`) is a directory.
@ -15,6 +16,7 @@ use crate::brain::Brain;
use crate::error::{Error, Result};
use crate::fsx::write_atomic_json;
use crate::paths;
use crate::scope::Scope;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
@ -31,6 +33,19 @@ impl ClaudeCodeAdapter {
fn user_config_dir(&self) -> PathBuf {
paths::resolve_home().join(".claude")
}
fn project_config_dir(&self) -> Option<PathBuf> {
std::env::current_dir().ok().map(|p| p.join(".claude"))
}
fn dir_for_scope(&self, scope: Scope) -> PathBuf {
match scope {
Scope::User => self.user_config_dir(),
Scope::Project => self
.project_config_dir()
.unwrap_or_else(|| PathBuf::from(".claude")),
}
}
}
impl Default for ClaudeCodeAdapter {
@ -51,8 +66,12 @@ impl ClientAdapter for ClaudeCodeAdapter {
cwd_local || self.user_config_dir().is_dir()
}
fn attach(&self, brain: &Brain) -> Result<()> {
let cfg = self.config_path();
fn supported_scopes(&self) -> &[Scope] {
&[Scope::User, Scope::Project]
}
fn attach(&self, brain: &Brain, scope: Scope) -> Result<()> {
let cfg = self.config_path(scope);
if let Some(parent) = cfg.parent() {
std::fs::create_dir_all(parent)?;
}
@ -61,8 +80,8 @@ impl ClientAdapter for ClaudeCodeAdapter {
write_atomic_json(&cfg, &doc)
}
fn detach(&self) -> Result<()> {
let cfg = self.config_path();
fn detach(&self, _brain_name: &str, scope: Scope) -> Result<()> {
let cfg = self.config_path(scope);
if !cfg.is_file() {
return Ok(());
}
@ -71,8 +90,8 @@ impl ClientAdapter for ClaudeCodeAdapter {
write_atomic_json(&cfg, &doc)
}
fn config_path(&self) -> PathBuf {
self.user_config_dir().join("settings.json")
fn config_path(&self, scope: Scope) -> PathBuf {
self.dir_for_scope(scope).join("settings.json")
}
fn post_attach_hint(&self) -> &str {

View file

@ -28,6 +28,7 @@ use crate::brain::Brain;
use crate::error::{Error, Result};
use crate::fsx::write_atomic;
use crate::paths;
use crate::scope::Scope;
use serde_json::{json, Value};
use std::path::PathBuf;
@ -81,7 +82,12 @@ impl ClientAdapter for ContinueAdapter {
self.continue_dir().is_dir()
}
fn attach(&self, brain: &Brain) -> Result<()> {
fn supported_scopes(&self) -> &[Scope] {
// Continue has no per-project MCP config surface today — user only.
&[Scope::User]
}
fn attach(&self, brain: &Brain, _scope: Scope) -> Result<()> {
let (form, cfg) = self.pick_form_and_path();
if let Some(parent) = cfg.parent() {
std::fs::create_dir_all(parent)?;
@ -91,7 +97,7 @@ impl ClientAdapter for ContinueAdapter {
write_doc(&cfg, form, &doc)
}
fn detach(&self) -> Result<()> {
fn detach(&self, _brain_name: &str, _scope: Scope) -> Result<()> {
let (form, cfg) = self.pick_form_and_path();
if !cfg.is_file() {
return Ok(());
@ -101,7 +107,7 @@ impl ClientAdapter for ContinueAdapter {
write_doc(&cfg, form, &doc)
}
fn config_path(&self) -> PathBuf {
fn config_path(&self, _scope: Scope) -> PathBuf {
self.pick_form_and_path().1
}

View file

@ -1,7 +1,10 @@
//! Cursor adapter — writes MCP server entry to Cursor's MCP config.
//!
//! Config path: `$CWD/.cursor/mcp.json` if the project-local `.cursor/`
//! dir exists, else `~/.cursor/mcp.json`. Detection fires if either dir
//! Scope:
//! - `Scope::User` → `~/.cursor/mcp.json`
//! - `Scope::Project` → `$CWD/.cursor/mcp.json`
//!
//! Detection fires if either the user-scope dir or a project-scope dir
//! exists. Schema [UNVERIFIED — matches Claude Desktop MCP convention]:
//! `{ "mcpServers": { "keisei": { "command": "...", "args": [] } } }`.
//!
@ -14,6 +17,7 @@ use crate::brain::Brain;
use crate::error::{Error, Result};
use crate::fsx::write_atomic_json;
use crate::paths;
use crate::scope::Scope;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
@ -35,13 +39,13 @@ impl CursorAdapter {
std::env::current_dir().ok().map(|p| p.join(".cursor"))
}
fn pick_config_path(&self) -> PathBuf {
if let Some(proj) = self.project_cursor_dir() {
if proj.is_dir() {
return proj.join("mcp.json");
}
fn dir_for_scope(&self, scope: Scope) -> PathBuf {
match scope {
Scope::User => self.home_cursor_dir(),
Scope::Project => self
.project_cursor_dir()
.unwrap_or_else(|| PathBuf::from(".cursor")),
}
self.home_cursor_dir().join("mcp.json")
}
}
@ -64,8 +68,12 @@ impl ClientAdapter for CursorAdapter {
proj || self.home_cursor_dir().is_dir()
}
fn attach(&self, brain: &Brain) -> Result<()> {
let cfg = self.config_path();
fn supported_scopes(&self) -> &[Scope] {
&[Scope::User, Scope::Project]
}
fn attach(&self, brain: &Brain, scope: Scope) -> Result<()> {
let cfg = self.config_path(scope);
if let Some(parent) = cfg.parent() {
std::fs::create_dir_all(parent)?;
}
@ -74,8 +82,8 @@ impl ClientAdapter for CursorAdapter {
write_atomic_json(&cfg, &doc)
}
fn detach(&self) -> Result<()> {
let cfg = self.config_path();
fn detach(&self, _brain_name: &str, scope: Scope) -> Result<()> {
let cfg = self.config_path(scope);
if !cfg.is_file() {
return Ok(());
}
@ -84,8 +92,8 @@ impl ClientAdapter for CursorAdapter {
write_atomic_json(&cfg, &doc)
}
fn config_path(&self) -> PathBuf {
self.pick_config_path()
fn config_path(&self, scope: Scope) -> PathBuf {
self.dir_for_scope(scope).join("mcp.json")
}
fn post_attach_hint(&self) -> &str {

View file

@ -32,6 +32,7 @@ use crate::brain::Brain;
use crate::error::{Error, Result};
use crate::fsx::write_atomic_json;
use crate::paths;
use crate::scope::Scope;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
@ -74,8 +75,13 @@ impl ClientAdapter for ZedAdapter {
self.settings_dir().is_dir()
}
fn attach(&self, brain: &Brain) -> Result<()> {
let cfg = self.config_path();
fn supported_scopes(&self) -> &[Scope] {
// Zed config is host-global — no per-project settings.json today.
&[Scope::User]
}
fn attach(&self, brain: &Brain, _scope: Scope) -> Result<()> {
let cfg = self.config_path(Scope::User);
if let Some(parent) = cfg.parent() {
std::fs::create_dir_all(parent)?;
}
@ -85,8 +91,8 @@ impl ClientAdapter for ZedAdapter {
Ok(())
}
fn detach(&self) -> Result<()> {
let cfg = self.config_path();
fn detach(&self, _brain_name: &str, _scope: Scope) -> Result<()> {
let cfg = self.config_path(Scope::User);
if !cfg.is_file() {
return Ok(());
}
@ -96,7 +102,7 @@ impl ClientAdapter for ZedAdapter {
Ok(())
}
fn config_path(&self) -> PathBuf {
fn config_path(&self, _scope: Scope) -> PathBuf {
self.settings_file()
}

View file

@ -1,8 +1,8 @@
//! `keisei attach <brain-path>` implementation.
//! `keisei attach <brain-path> [--scope=<user|project>]` implementation.
//!
//! Constructor Pattern: single responsibility — orchestrate the 7-step
//! attach ritual (canonicalize → load manifest → validate schema →
//! detect client → adapter.attach → write SSoT marker v2 → print summary).
//! detect client → adapter.attach → write SSoT marker → print summary).
//! No I/O here beyond what the `brain`, `adapter`, and `config` modules
//! already own.
@ -10,44 +10,75 @@ use crate::adapter::{detect_active, ClientAdapter};
use crate::brain::Brain;
use crate::config::{self, AttachRecord, Attachment};
use crate::display::sanitize_display;
use crate::error::Result;
use crate::error::{Error, Result};
use crate::scope::Scope;
use std::path::Path;
pub fn run(brain_path: &Path) -> Result<()> {
pub fn run(brain_path: &Path, scope: Scope) -> Result<()> {
let brain = Brain::load(brain_path)?;
let adapter = detect_active()?;
adapter.attach(&brain)?;
let rec = build_record(&brain, adapter.as_ref());
ensure_scope_supported(adapter.as_ref(), scope)?;
adapter.attach(&brain, scope)?;
let rec = build_record(&brain, adapter.as_ref(), scope);
let marker = config::write(&rec)?;
print_summary(&brain, adapter.as_ref(), &marker);
print_summary(&brain, adapter.as_ref(), scope, &marker);
Ok(())
}
fn build_record(brain: &Brain, adapter: &dyn ClientAdapter) -> AttachRecord {
fn ensure_scope_supported(adapter: &dyn ClientAdapter, scope: Scope) -> Result<()> {
if adapter.supports_scope(scope) {
return Ok(());
}
Err(Error::ScopeUnsupported {
client: adapter.name().to_string(),
scope: scope.to_string(),
supported: adapter
.supported_scopes()
.iter()
.map(|s| s.to_string())
.collect(),
})
}
fn build_record(brain: &Brain, adapter: &dyn ClientAdapter, scope: Scope) -> AttachRecord {
AttachRecord {
brain_path: brain.root.to_string_lossy().into_owned(),
brain_name: brain.name().to_string(),
attached_at: config::now_utc_string(),
attachments: vec![Attachment {
client_type: adapter.name().to_string(),
config_path: adapter.config_path().to_string_lossy().into_owned(),
config_path: adapter.config_path(scope).to_string_lossy().into_owned(),
scope,
}],
}
}
fn print_summary(brain: &Brain, adapter: &dyn ClientAdapter, marker: &std::path::Path) {
fn print_summary(
brain: &Brain,
adapter: &dyn ClientAdapter,
scope: Scope,
marker: &std::path::Path,
) {
let brain_name = sanitize_display(brain.name());
let brain_path = sanitize_display(&brain.root.to_string_lossy());
println!("attached brain '{}' to {}", brain_name, adapter.name());
println!(
"attached brain '{}' to {} ({} scope)",
brain_name,
adapter.name(),
scope
);
println!(" brain path: {}", brain_path);
match brain.mcp_server_path() {
Ok(p) => {
let mcp_path = sanitize_display(&p.to_string_lossy());
println!(" mcp server: {}", mcp_path);
}
Err(e) => println!(" mcp server: [unresolved — {}]", sanitize_display(&e.to_string())),
Err(e) => println!(
" mcp server: [unresolved — {}]",
sanitize_display(&e.to_string())
),
}
println!(" client cfg: {}", adapter.config_path().display());
println!(" client cfg: {}", adapter.config_path(scope).display());
println!(" marker: {}", marker.display());
println!("{}", adapter.post_attach_hint());
}

View file

@ -1,6 +1,6 @@
//! SSoT for the active attach: `~/.claude/keisei-attached.toml`.
//! SSoT for the active attach: `~/.keisei/attached.toml` (v0.21+).
//!
//! Schema v2 (v0.19, multi-client):
//! Schema v3 (v0.21, scope-aware):
//! ```toml
//! brain_path = "/Volumes/Brain1"
//! brain_name = "my-ai-brain"
@ -9,37 +9,47 @@
//! [[attachments]]
//! 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"
//! ```
//!
//! Schema v1 (v0.18, single-client, still readable):
//! ```toml
//! brain_path = "/Volumes/Brain1"
//! brain_name = "my-ai-brain"
//! client_type = "claude-code"
//! attached_at = "2026-04-22T14:23:00Z"
//! ```
//! On first access we parse either shape into the v2 `AttachRecord`.
//! 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.
//!
//! Constructor Pattern: single responsibility — read/write the attach
//! marker. No knowledge of Brain schema or adapter behaviour.
//! marker + one-shot location migration. No knowledge of Brain schema or
//! adapter behaviour.
//!
//! Testability: `$KEISEI_HOME` overrides `$HOME` so integration tests
//! isolate state per tmpdir.
use crate::error::Result;
use crate::scope::Scope;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const ATTACHED_FILENAME: &str = "keisei-attached.toml";
/// v0.21+ filename. The marker lives at `$KEISEI_HOME/.keisei/attached.toml`.
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";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct Attachment {
pub client_type: String,
pub config_path: String,
#[serde(default)]
pub scope: Scope,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@ -71,7 +81,8 @@ impl AttachRecord {
}
}
/// Raw wire shape: accepts BOTH v1 (flat `client_type`) and v2 (list).
/// 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,
@ -84,13 +95,17 @@ struct WireRecord {
}
impl WireRecord {
fn into_v2(self) -> AttachRecord {
fn into_current(self) -> AttachRecord {
let attachments = if !self.attachments.is_empty() {
self.attachments
} else if let Some(ct) = self.client_type {
// v1 → v2 migration. config_path unknown at migration time;
// 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() }]
vec![Attachment {
client_type: ct,
config_path: String::new(),
scope: Scope::User,
}]
} else {
Vec::new()
};
@ -103,12 +118,23 @@ impl WireRecord {
}
}
pub fn home_root() -> PathBuf {
crate::paths::resolve_home().join(".claude")
/// Keisei's state directory — `$KEISEI_HOME/.keisei/`.
pub fn keisei_state_dir() -> PathBuf {
crate::paths::keisei_state_dir()
}
/// Current marker path (v0.21+): `$KEISEI_HOME/.keisei/attached.toml`.
pub fn attached_path() -> PathBuf {
home_root().join(ATTACHED_FILENAME)
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()`.
pub fn legacy_attached_path() -> PathBuf {
crate::paths::resolve_home()
.join(".claude")
.join(LEGACY_ATTACHED_FILENAME)
}
pub fn write(rec: &AttachRecord) -> Result<PathBuf> {
@ -130,14 +156,17 @@ pub fn write(rec: &AttachRecord) -> Result<PathBuf> {
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.
pub fn read() -> Result<Option<AttachRecord>> {
migrate_from_legacy()?;
let path = attached_path();
if !path.is_file() {
return Ok(None);
}
let raw = std::fs::read_to_string(&path)?;
let wire: WireRecord = toml::from_str(&raw)?;
Ok(Some(wire.into_v2()))
Ok(Some(wire.into_current()))
}
pub fn delete() -> Result<bool> {
@ -149,6 +178,35 @@ pub fn delete() -> Result<bool> {
Ok(true)
}
/// 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();
if current.is_file() || !legacy.is_file() {
return Ok(());
}
let raw = std::fs::read_to_string(&legacy)?;
if let Some(parent) = current.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&current, &raw)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&current)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&current, perms)?;
}
std::fs::remove_file(&legacy)?;
eprintln!(
"keisei: migrated marker from ~/.claude/keisei-attached.toml to ~/.keisei/attached.toml"
);
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.
pub fn now_utc_string() -> String {

View file

@ -1,14 +1,16 @@
//! `keisei detach` implementation.
//!
//! Constructor Pattern: single responsibility — read the v2 marker,
//! iterate recorded client attachments, call `adapter.detach()` 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.
//! 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.
use crate::adapter;
use crate::config::{self, AttachRecord};
use crate::config::{self, AttachRecord, Attachment};
use crate::error::Result;
use crate::scope::Scope;
pub fn run() -> Result<()> {
let Some(rec) = config::read()? else {
@ -30,37 +32,37 @@ pub fn run() -> Result<()> {
Ok(())
}
/// For each attachment in the marker, run `adapter.detach()`.
/// 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)>) {
let mut ok = Vec::new();
let mut err = Vec::new();
let names = resolve_client_names(rec);
for name in names {
match adapter::by_name(&name) {
Some(a) => match a.detach() {
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((name, e.to_string())),
Err(e) => err.push((client_name, e.to_string())),
},
None => err.push((name, "unknown adapter (not registered)".to_string())),
None => err.push((client_name, "unknown adapter (not registered)".to_string())),
}
}
(ok, err)
}
/// v2 attachments carry the full list; a v1 marker that migrated with an
/// empty client_type fallback falls through to every registered adapter
/// 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_client_names(rec: &AttachRecord) -> Vec<String> {
fn resolve_detach_plan(rec: &AttachRecord) -> Vec<(String, Scope)> {
if rec.attachments.is_empty() {
return adapter::all()
.iter()
.map(|a| a.name().to_string())
.map(|a| (a.name().to_string(), Scope::User))
.collect();
}
rec.attachments
.iter()
.map(|a| a.client_type.clone())
.map(|Attachment { client_type, scope, .. }| (client_type.clone(), *scope))
.collect()
}

View file

@ -73,6 +73,17 @@ pub enum Error {
)]
BrainIsSymlink { input: PathBuf, target: PathBuf },
#[error(
"adapter '{client}' does not support scope '{scope}' \
(supported: {})",
supported.join(", ")
)]
ScopeUnsupported {
client: String,
scope: String,
supported: Vec<String>,
},
#[error("failed to load brain at {path}: {source}")]
BrainLoad {
path: PathBuf,

View file

@ -3,9 +3,15 @@
//!
//! Constructor Pattern: single responsibility — render a tabular view.
//! No state mutation, no config touches.
//!
//! v0.21: adapters can advertise multiple scopes — we show the user-scope
//! config path by default (the fan-out target for `keisei mount`), plus a
//! trailing `scopes=...` column so an operator can see which adapters can
//! also take a `--scope=project` attach.
use crate::adapter;
use crate::error::Result;
use crate::scope::Scope;
pub fn run() -> Result<()> {
let rows: Vec<Row> = adapter::all()
@ -13,7 +19,13 @@ pub fn run() -> Result<()> {
.map(|a| Row {
name: a.name().to_string(),
detected: a.detect(),
config_path: a.config_path().to_string_lossy().into_owned(),
config_path: a.config_path(Scope::User).to_string_lossy().into_owned(),
scopes: a
.supported_scopes()
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join("|"),
})
.collect();
print_table(&rows);
@ -24,14 +36,30 @@ struct Row {
name: String,
detected: bool,
config_path: String,
scopes: String,
}
fn print_table(rows: &[Row]) {
let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0).max(7);
println!("{:<w1$} detected config_path", "adapter", w1 = name_w);
println!("{:<w1$} -------- -----------", "-------", w1 = name_w);
println!(
"{:<w1$} detected scopes config_path (user)",
"adapter",
w1 = name_w
);
println!(
"{:<w1$} -------- ------------- ------------------",
"-------",
w1 = name_w
);
for r in rows {
let mark = if r.detected { "yes " } else { "no " };
println!("{:<w1$} {} {}", r.name, mark, r.config_path, w1 = name_w);
println!(
"{:<w1$} {} {:<13} {}",
r.name,
mark,
r.scopes,
r.config_path,
w1 = name_w
);
}
}

View file

@ -1,4 +1,4 @@
//! keisei — exobrain attach/status CLI (v0.19 multi-client).
//! keisei — exobrain attach/status CLI (v0.21 scope-aware).
//!
//! Constructor Pattern: main.rs = clap parse + dispatch only. All
//! subcommand logic lives in sibling modules
@ -17,12 +17,15 @@ mod fsx;
mod list;
mod mount;
mod paths;
mod scope;
mod status;
use clap::{Parser, Subcommand};
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
use std::process::ExitCode;
use crate::scope::Scope;
#[derive(Parser)]
#[command(
name = "keisei",
@ -34,14 +37,34 @@ struct Cli {
cmd: Cmd,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum ScopeArg {
User,
Project,
}
impl From<ScopeArg> for Scope {
fn from(value: ScopeArg) -> Self {
match value {
ScopeArg::User => Scope::User,
ScopeArg::Project => Scope::Project,
}
}
}
#[derive(Subcommand)]
enum Cmd {
/// Attach a brain to the single currently detected AI client.
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)]
scope: ScopeArg,
},
/// Attach a brain to EVERY detected AI client in one shot.
/// Attach a brain to EVERY detected AI client in one shot (user scope).
Mount {
/// Path to the brain directory (must contain manifest.toml).
brain_path: PathBuf,
@ -57,7 +80,7 @@ enum Cmd {
fn main() -> ExitCode {
let cli = Cli::parse();
let res = match cli.cmd {
Cmd::Attach { brain_path } => attach::run(&brain_path),
Cmd::Attach { brain_path, scope } => attach::run(&brain_path, scope.into()),
Cmd::Mount { brain_path } => mount::run(&brain_path),
Cmd::Detach => detach::run(),
Cmd::Status => status::run(),

View file

@ -1,15 +1,19 @@
//! `keisei mount <brain-path>` — attach to every detected client.
//! `keisei mount <brain-path>` — attach to every detected client at user scope.
//!
//! Constructor Pattern: single responsibility — orchestrate the fan-out
//! (load brain → enumerate adapters → attach each one whose `detect()`
//! fires → collect successes/failures → write v2 marker with the
//! successful list → print summary). No config-schema knowledge beyond
//! what the `config` module already owns.
//! fires, at `Scope::User` → collect successes/failures → write marker
//! with the successful list → 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`.
use crate::adapter;
use crate::brain::Brain;
use crate::config::{self, AttachRecord, Attachment};
use crate::error::{Error, Result};
use crate::scope::Scope;
use std::path::Path;
pub fn run(brain_path: &Path) -> Result<()> {
@ -31,7 +35,7 @@ struct Success {
}
/// Returns `(succeeded, failed)` where:
/// - succeeded: adapters that detected AND attached OK
/// - succeeded: adapters that detected AND attached OK at `Scope::User`
/// - 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)>) {
@ -41,10 +45,13 @@ fn mount_all(brain: &Brain) -> (Vec<Success>, Vec<(String, String)>) {
if !a.detect() {
continue;
}
match a.attach(brain) {
match a.attach(brain, Scope::User) {
Ok(()) => ok.push(Success {
client_type: a.name().to_string(),
config_path: a.config_path().to_string_lossy().into_owned(),
config_path: a
.config_path(Scope::User)
.to_string_lossy()
.into_owned(),
}),
Err(e) => err.push((a.name().to_string(), e.to_string())),
}
@ -62,6 +69,7 @@ fn build_record(brain: &Brain, succeeded: &[Success]) -> AttachRecord {
.map(|s| Attachment {
client_type: s.client_type.clone(),
config_path: s.config_path.clone(),
scope: Scope::User,
})
.collect(),
}

View file

@ -1,7 +1,7 @@
//! Host path resolution — SSoT for `$KEISEI_HOME` / `$HOME` fallback.
//!
//! Constructor Pattern: single responsibility — resolve the user's home
//! directory for every adapter + the attach marker. `$KEISEI_HOME`
//! directory for every adapter + the keisei state dir. `$KEISEI_HOME`
//! overrides `$HOME` so integration tests can isolate state per tmpdir.
//! Adapters compose on top of this SSoT; no duplication of the env-var
//! chain across the codebase.
@ -21,3 +21,14 @@ pub fn resolve_home() -> PathBuf {
.or_else(|| std::env::var("HOME").ok().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("."))
}
/// Keisei's own state directory (marker file + future per-tool state).
///
/// Rationale: v0.20 stored the marker under `~/.claude/keisei-attached.toml`
/// which baked a Claude-Code-specific subpath into a tool that must support
/// 4+ clients. v0.21 moves it to `~/.keisei/` — independent of any client's
/// config layout. Adapters still own their own per-client dirs
/// (`~/.claude/`, `~/.cursor/`, etc) via their own helpers.
pub fn keisei_state_dir() -> PathBuf {
resolve_home().join(".keisei")
}

View file

@ -0,0 +1,49 @@
//! `Scope` — whether an attach targets the host-wide (User) config or the
//! project-local (Project) config for an AI client.
//!
//! Constructor Pattern: single responsibility — a plain enum + its (de)serde
//! projection. No I/O, no adapter knowledge. Lives in its own file to keep
//! `adapter.rs` at one-concept (the trait itself).
//!
//! Default on deserialization is `Scope::User` so v0.20 markers (written
//! before this field existed) round-trip transparently.
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
/// Host-wide config — e.g. `~/.claude/settings.json`, `~/.cursor/mcp.json`.
User,
/// Project-local config — e.g. `./.claude/settings.json`, `./.cursor/mcp.json`.
Project,
}
impl Default for Scope {
fn default() -> Self {
Scope::User
}
}
impl fmt::Display for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Scope::User => f.write_str("user"),
Scope::Project => f.write_str("project"),
}
}
}
impl std::str::FromStr for Scope {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"user" => Ok(Scope::User),
"project" => Ok(Scope::Project),
other => Err(format!(
"unknown scope '{other}' — expected 'user' or 'project'"
)),
}
}
}

View file

@ -2,7 +2,7 @@
//!
//! Constructor Pattern: one scenario per test, one assertion target.
//! Each test runs with `KEISEI_HOME` pointed at a tempdir so nothing
//! touches the real `~/.claude`.
//! touches the real `~/.claude` / `~/.keisei`.
//!
//! Sources are loaded via `#[path]` — mirror of the kei-ledger pattern.
@ -10,6 +10,8 @@
mod error;
#[path = "../src/paths.rs"]
mod paths;
#[path = "../src/scope.rs"]
mod scope;
#[path = "../src/brain.rs"]
mod brain;
#[path = "../src/brain_validate.rs"]
@ -35,6 +37,7 @@ mod detach;
#[path = "../src/list.rs"]
mod list;
use crate::scope::Scope;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
@ -48,6 +51,13 @@ static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
_home: TempDir,
home_path: PathBuf,
}
impl EnvGuard {
fn home(&self) -> &Path {
&self.home_path
}
}
fn setup_home() -> EnvGuard {
@ -57,7 +67,12 @@ fn setup_home() -> EnvGuard {
// either CWD/.claude/settings.json OR $KEISEI_HOME/.claude/ to exist.
fs::create_dir_all(home.path().join(".claude")).unwrap();
std::env::set_var("KEISEI_HOME", home.path());
EnvGuard { _lock: lock, _home: home }
let home_path = home.path().to_path_buf();
EnvGuard {
_lock: lock,
_home: home,
home_path,
}
}
/// Variant of `setup_home` that does NOT pre-create the `.claude` dir.
@ -66,7 +81,19 @@ fn setup_home_bare() -> EnvGuard {
let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let home = tempfile::tempdir().unwrap();
std::env::set_var("KEISEI_HOME", home.path());
EnvGuard { _lock: lock, _home: home }
let home_path = home.path().to_path_buf();
EnvGuard {
_lock: lock,
_home: home,
home_path,
}
}
/// Path the Claude-Code adapter writes at user scope, given the
/// current `$KEISEI_HOME`. Used by tests that pre-seed or inspect
/// the client config file directly.
fn claude_user_settings() -> PathBuf {
paths::resolve_home().join(".claude").join("settings.json")
}
fn write_brain(root: &Path, schema: u32) -> PathBuf {
@ -95,7 +122,7 @@ fn attach_then_status_happy_path() {
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path()).expect("attach ok");
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
// Marker file exists with correct fields.
let rec = config::read().unwrap().expect("record present");
@ -112,7 +139,7 @@ fn attach_missing_manifest_errors() {
let _g = setup_home();
let empty = tempfile::tempdir().unwrap();
// No manifest.toml written.
let err = attach::run(empty.path()).unwrap_err();
let err = attach::run(empty.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::BrainNotFound(_)),
"got {err:?}"
@ -124,7 +151,7 @@ fn attach_unsupported_schema_errors() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 99);
let err = attach::run(brain_dir.path()).unwrap_err();
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::UnsupportedSchema { found: 99 }),
"got {err:?}"
@ -145,7 +172,7 @@ fn attach_writes_marker_with_expected_fields() {
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path()).expect("attach ok");
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
let rec = config::read().unwrap().expect("record present");
// brain_path stored as canonicalized absolute path.
@ -153,17 +180,23 @@ fn attach_writes_marker_with_expected_fields() {
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);
assert!(
!rec.attachments[0].config_path.is_empty(),
"config_path should be populated on v2 write"
"config_path should be populated on v2+ write"
);
// Marker file itself lives under $KEISEI_HOME/.claude/.
// Marker file itself lives under $KEISEI_HOME/.keisei/.
let marker = config::attached_path();
assert!(marker.is_file(), "marker not written at {}", marker.display());
assert!(
marker.ends_with(".keisei/attached.toml"),
"marker not in new location: {}",
marker.display()
);
// Settings.json got written and contains the server entry.
let settings = marker.parent().unwrap().join("settings.json");
let settings = claude_user_settings();
assert!(settings.is_file(), "settings.json not written");
let text = fs::read_to_string(&settings).unwrap();
assert!(text.contains("mcpServers"), "mcpServers key missing");
@ -171,7 +204,7 @@ fn attach_writes_marker_with_expected_fields() {
}
// -----------------------------------------------------------------------
// New v0.19 tests (multi-client).
// v0.19 tests (multi-client).
// -----------------------------------------------------------------------
#[test]
@ -192,6 +225,7 @@ fn mount_with_claude_code_only_detected() {
rec.client_names()
);
assert_eq!(rec.attachments[0].client_type, "claude-code");
assert_eq!(rec.attachments[0].scope, Scope::User);
}
#[test]
@ -216,8 +250,8 @@ fn detach_round_trip() {
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path()).expect("attach ok");
let settings = config::attached_path().parent().unwrap().join("settings.json");
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
let settings = claude_user_settings();
assert!(settings.is_file());
// Sanity: keisei entry is present BEFORE detach.
let before: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap();
@ -249,7 +283,7 @@ fn detach_round_trip() {
#[test]
fn detach_preserves_other_mcp_servers() {
let _g = setup_home();
let settings = config::home_root().join("settings.json");
let settings = claude_user_settings();
// Pre-populate with a user's pre-existing MCP server.
fs::write(
&settings,
@ -264,7 +298,7 @@ fn detach_preserves_other_mcp_servers() {
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path()).expect("attach ok");
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
detach::run().expect("detach ok");
let after: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap();
@ -352,7 +386,7 @@ fn manifest_with_absolute_mcp_server_rejected() {
let brain_dir = tempfile::tempdir().unwrap();
// Malicious manifest: absolute path to arbitrary host binary.
write_brain_raw_mcp(brain_dir.path(), "/usr/bin/curl");
let err = attach::run(brain_dir.path()).unwrap_err();
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::PathEscape(_)),
"expected PathEscape, got {err:?}"
@ -366,7 +400,7 @@ fn manifest_with_parent_traversal_rejected() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain_raw_mcp(brain_dir.path(), "../../etc/passwd");
let err = attach::run(brain_dir.path()).unwrap_err();
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::PathEscape(_)),
"expected PathEscape, got {err:?}"
@ -380,7 +414,7 @@ fn manifest_with_invalid_name_rejected() {
let brain_dir = tempfile::tempdir().unwrap();
// "claude-ide!" contains `!` — forbidden by ^[a-z][a-z0-9_-]{0,63}$.
write_brain_raw_name(brain_dir.path(), "claude-ide!");
let err = attach::run(brain_dir.path()).unwrap_err();
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::InvalidName(ref s) if s == "claude-ide!"),
"expected InvalidName(\"claude-ide!\"), got {err:?}"
@ -401,7 +435,7 @@ fn brain_path_is_symlink_rejected() {
#[cfg(windows)]
std::os::windows::fs::symlink_dir(target.path(), &link).unwrap();
let err = attach::run(&link).unwrap_err();
let err = attach::run(&link, Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::BrainIsSymlink { .. }),
"expected BrainIsSymlink, got {err:?}"
@ -414,7 +448,7 @@ fn brain_path_is_symlink_rejected() {
fn attach_refuses_to_clobber_existing_mcp_entry() {
let _g = setup_home();
// Pre-populate settings.json with a DIFFERENT `keisei` entry.
let settings = config::home_root().join("settings.json");
let settings = claude_user_settings();
fs::create_dir_all(settings.parent().unwrap()).unwrap();
fs::write(
&settings,
@ -431,7 +465,7 @@ fn attach_refuses_to_clobber_existing_mcp_entry() {
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
let err = attach::run(brain_dir.path()).unwrap_err();
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::NameConflict { .. }),
"expected NameConflict, got {err:?}"
@ -456,7 +490,8 @@ fn attach_refuses_to_clobber_existing_mcp_entry() {
#[test]
fn schema_v1_to_v2_migration() {
let _g = setup_home();
// Hand-write a v1 marker.
// Hand-write a v1 marker at the NEW location (location migration is
// tested separately). v1 schema has flat `client_type` and no list.
let marker = config::attached_path();
if let Some(parent) = marker.parent() {
fs::create_dir_all(parent).unwrap();
@ -482,6 +517,8 @@ attached_at = "2026-04-22T00:00:00Z"
assert_eq!(rec.attachments[0].client_type, "claude-code");
// v1 didn't carry config_path; migration leaves it blank.
assert_eq!(rec.attachments[0].config_path, "");
// v1 didn't carry scope; default is User.
assert_eq!(rec.attachments[0].scope, Scope::User);
assert!(rec.has_client("claude-code"));
}
@ -497,7 +534,7 @@ fn marker_file_has_0600_perms_on_unix() {
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path()).expect("attach ok");
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
let marker = config::attached_path();
let mode = fs::metadata(&marker).unwrap().permissions().mode() & 0o777;
@ -617,7 +654,7 @@ fn manifest_too_large_rejected() {
// 100 KiB manifest — well above the 64 KiB cap.
write_brain_with_oversize_manifest(brain_dir.path(), 100 * 1024);
let err = attach::run(brain_dir.path()).unwrap_err();
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(
err,
@ -712,3 +749,180 @@ fn post_attach_hint_is_adapter_specific() {
"zed hint lost ':reload' marker: {zed}"
);
}
// -----------------------------------------------------------------------
// v0.21 — SSoT relocation + Scope enum tests.
// -----------------------------------------------------------------------
#[test]
fn legacy_marker_migrates_on_first_read() {
let g = setup_home();
// Seed a v2 marker at the LEGACY path
// ($KEISEI_HOME/.claude/keisei-attached.toml), with no new-location
// file. Simulates an upgrade from v0.20 → v0.21.
let legacy = g.home().join(".claude").join("keisei-attached.toml");
fs::create_dir_all(legacy.parent().unwrap()).unwrap();
let body = r#"brain_path = "/tmp/legacy-brain"
brain_name = "legacy-brain"
attached_at = "2026-04-22T00:00:00Z"
[[attachments]]
client_type = "claude-code"
config_path = "/tmp/fake/settings.json"
"#;
fs::write(&legacy, body).unwrap();
// New-location MUST NOT exist yet.
let current = g.home().join(".keisei").join("attached.toml");
assert!(
!current.exists(),
"new marker pre-existed before read(): {}",
current.display()
);
// read() performs the one-shot migration.
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.attachments.len(), 1);
assert_eq!(rec.attachments[0].client_type, "claude-code");
// Default scope for pre-v0.21 markers is User.
assert_eq!(rec.attachments[0].scope, Scope::User);
// Post-conditions: new file exists, legacy file gone.
assert!(
current.is_file(),
"migration did not create new marker at {}",
current.display()
);
assert!(
!legacy.exists(),
"legacy marker still present at {} after migration",
legacy.display()
);
}
#[test]
fn attach_with_project_scope_writes_local_config() {
let _g = setup_home();
// Run from a CWD where the adapter's project-scope target lives.
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::Project).expect("attach --scope=project ok");
// Project-local file must be written (under the CWD we set above).
let project_settings = workdir.path().join(".claude").join("settings.json");
assert!(
project_settings.is_file(),
"project-scope settings.json missing at {}",
project_settings.display()
);
// User-scope file must NOT have been created by this attach.
let user_settings = claude_user_settings();
assert!(
!user_settings.is_file(),
"user-scope settings.json leaked when scope=project: {}",
user_settings.display()
);
// Marker records scope=Project.
let rec = config::read().unwrap().expect("record");
assert_eq!(rec.attachments.len(), 1);
assert_eq!(rec.attachments[0].scope, Scope::Project);
std::env::set_current_dir(prev_cwd).unwrap();
}
#[test]
fn attach_user_scope_still_default() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
// main.rs default is Scope::User — exercise the path explicitly here.
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
let rec = config::read().unwrap().expect("record");
assert_eq!(rec.attachments.len(), 1);
assert_eq!(rec.attachments[0].scope, Scope::User);
assert!(claude_user_settings().is_file());
}
#[test]
fn scope_unsupported_by_adapter_errors() {
let _g = setup_home();
// Force the Zed adapter to the front of detection by pre-creating its
// settings dir, and suppress claude-code's dir so detect_active picks Zed.
// Remove the .claude dir that setup_home pre-created.
let home = paths::resolve_home();
fs::remove_dir_all(home.join(".claude")).ok();
// Also suppress any CWD-local .claude (claude_code's detect checks CWD).
let workdir = tempfile::tempdir().unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(workdir.path()).unwrap();
// Seed Zed's config dir (platform-specific).
let zed_dir = if cfg!(target_os = "macos") {
home.join("Library/Application Support/Zed")
} else {
home.join(".config/zed")
};
fs::create_dir_all(&zed_dir).unwrap();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
// Zed declares supported_scopes() = [User], so project scope must error.
let err = attach::run(brain_dir.path(), Scope::Project).unwrap_err();
assert!(
matches!(err, error::Error::ScopeUnsupported { ref client, .. } if client == "zed"),
"expected ScopeUnsupported for zed, got {err:?}"
);
// Marker must not be written on validation failure.
assert!(config::read().unwrap().is_none());
std::env::set_current_dir(prev_cwd).unwrap();
}
#[test]
fn detach_respects_scope_from_marker() {
let _g = setup_home();
// Attach at project scope in a workdir.
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::Project).expect("attach project ok");
let project_settings = workdir.path().join(".claude").join("settings.json");
assert!(project_settings.is_file(), "project settings absent post-attach");
let before: Value =
serde_json::from_str(&fs::read_to_string(&project_settings).unwrap()).unwrap();
assert!(
before.get("mcpServers").and_then(|s| s.get("keisei")).is_some(),
"keisei entry missing pre-detach: {before}"
);
detach::run().expect("detach ok");
// keisei entry gone from project-scope file.
let after: Value =
serde_json::from_str(&fs::read_to_string(&project_settings).unwrap()).unwrap();
let has_keisei = after
.get("mcpServers")
.and_then(|s| s.get("keisei"))
.is_some();
assert!(!has_keisei, "keisei entry survived detach: {after}");
// Marker gone.
assert!(config::read().unwrap().is_none());
std::env::set_current_dir(prev_cwd).unwrap();
}