From 81e3b58533a2332a2ae8cf4e863b8439e5af3fb5 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Wed, 22 Apr 2026 17:56:10 +0800 Subject: [PATCH] feat(v0.21): keisei SSoT relocation + Scope enum (user/project) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --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) --- CHANGELOG.md | 4 + _primitives/_rust/keisei/src/adapter.rs | 28 +- .../_rust/keisei/src/adapters/claude_code.rs | 35 ++- .../keisei/src/adapters/continue_adapter.rs | 12 +- .../_rust/keisei/src/adapters/cursor.rs | 36 ++- _primitives/_rust/keisei/src/adapters/zed.rs | 16 +- _primitives/_rust/keisei/src/attach.rs | 57 +++- _primitives/_rust/keisei/src/config.rs | 98 +++++-- _primitives/_rust/keisei/src/detach.rs | 38 +-- _primitives/_rust/keisei/src/error.rs | 11 + _primitives/_rust/keisei/src/list.rs | 36 ++- _primitives/_rust/keisei/src/main.rs | 31 ++- _primitives/_rust/keisei/src/mount.rs | 22 +- _primitives/_rust/keisei/src/paths.rs | 13 +- _primitives/_rust/keisei/src/scope.rs | 49 ++++ _primitives/_rust/keisei/tests/integration.rs | 262 ++++++++++++++++-- 16 files changed, 622 insertions(+), 126 deletions(-) create mode 100644 _primitives/_rust/keisei/src/scope.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c73303..a7605c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` (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. diff --git a/_primitives/_rust/keisei/src/adapter.rs b/_primitives/_rust/keisei/src/adapter.rs index 18c48f7..b36360c 100644 --- a/_primitives/_rust/keisei/src/adapter.rs +++ b/_primitives/_rust/keisei/src/adapter.rs @@ -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. diff --git a/_primitives/_rust/keisei/src/adapters/claude_code.rs b/_primitives/_rust/keisei/src/adapters/claude_code.rs index a00d2f1..58580d3 100644 --- a/_primitives/_rust/keisei/src/adapters/claude_code.rs +++ b/_primitives/_rust/keisei/src/adapters/claude_code.rs @@ -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 { + 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 { diff --git a/_primitives/_rust/keisei/src/adapters/continue_adapter.rs b/_primitives/_rust/keisei/src/adapters/continue_adapter.rs index 2f93a24..6dc925d 100644 --- a/_primitives/_rust/keisei/src/adapters/continue_adapter.rs +++ b/_primitives/_rust/keisei/src/adapters/continue_adapter.rs @@ -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 } diff --git a/_primitives/_rust/keisei/src/adapters/cursor.rs b/_primitives/_rust/keisei/src/adapters/cursor.rs index bb18b87..c3be943 100644 --- a/_primitives/_rust/keisei/src/adapters/cursor.rs +++ b/_primitives/_rust/keisei/src/adapters/cursor.rs @@ -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 { diff --git a/_primitives/_rust/keisei/src/adapters/zed.rs b/_primitives/_rust/keisei/src/adapters/zed.rs index 0e2296b..d2dd1ab 100644 --- a/_primitives/_rust/keisei/src/adapters/zed.rs +++ b/_primitives/_rust/keisei/src/adapters/zed.rs @@ -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() } diff --git a/_primitives/_rust/keisei/src/attach.rs b/_primitives/_rust/keisei/src/attach.rs index 2daf672..53d90a8 100644 --- a/_primitives/_rust/keisei/src/attach.rs +++ b/_primitives/_rust/keisei/src/attach.rs @@ -1,8 +1,8 @@ -//! `keisei attach ` 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 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()); } diff --git a/_primitives/_rust/keisei/src/config.rs b/_primitives/_rust/keisei/src/config.rs index e73808a..475204a 100644 --- a/_primitives/_rust/keisei/src/config.rs +++ b/_primitives/_rust/keisei/src/config.rs @@ -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 { @@ -130,14 +156,17 @@ pub fn write(rec: &AttachRecord) -> Result { 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> { + 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 { @@ -149,6 +178,35 @@ pub fn delete() -> Result { 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(¤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)?; + } + 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 { diff --git a/_primitives/_rust/keisei/src/detach.rs b/_primitives/_rust/keisei/src/detach.rs index 2e4b98c..7f8cb0b 100644 --- a/_primitives/_rust/keisei/src/detach.rs +++ b/_primitives/_rust/keisei/src/detach.rs @@ -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, 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 { +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() } diff --git a/_primitives/_rust/keisei/src/error.rs b/_primitives/_rust/keisei/src/error.rs index cf99eec..6341858 100644 --- a/_primitives/_rust/keisei/src/error.rs +++ b/_primitives/_rust/keisei/src/error.rs @@ -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, + }, + #[error("failed to load brain at {path}: {source}")] BrainLoad { path: PathBuf, diff --git a/_primitives/_rust/keisei/src/list.rs b/_primitives/_rust/keisei/src/list.rs index a875543..d075bfd 100644 --- a/_primitives/_rust/keisei/src/list.rs +++ b/_primitives/_rust/keisei/src/list.rs @@ -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 = 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::>() + .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!("{: 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(), diff --git a/_primitives/_rust/keisei/src/mount.rs b/_primitives/_rust/keisei/src/mount.rs index 7b13885..cffb8ca 100644 --- a/_primitives/_rust/keisei/src/mount.rs +++ b/_primitives/_rust/keisei/src/mount.rs @@ -1,15 +1,19 @@ -//! `keisei mount ` — attach to every detected client. +//! `keisei mount ` — 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, Vec<(String, String)>) { @@ -41,10 +45,13 @@ fn mount_all(brain: &Brain) -> (Vec, 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(), } diff --git a/_primitives/_rust/keisei/src/paths.rs b/_primitives/_rust/keisei/src/paths.rs index 54a00cb..96bf049 100644 --- a/_primitives/_rust/keisei/src/paths.rs +++ b/_primitives/_rust/keisei/src/paths.rs @@ -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") +} diff --git a/_primitives/_rust/keisei/src/scope.rs b/_primitives/_rust/keisei/src/scope.rs new file mode 100644 index 0000000..2cf15df --- /dev/null +++ b/_primitives/_rust/keisei/src/scope.rs @@ -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 { + match s { + "user" => Ok(Scope::User), + "project" => Ok(Scope::Project), + other => Err(format!( + "unknown scope '{other}' — expected 'user' or 'project'" + )), + } + } +} diff --git a/_primitives/_rust/keisei/tests/integration.rs b/_primitives/_rust/keisei/tests/integration.rs index 495f1e2..4652796 100644 --- a/_primitives/_rust/keisei/tests/integration.rs +++ b/_primitives/_rust/keisei/tests/integration.rs @@ -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(); +}