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:
parent
e372c95f29
commit
81e3b58533
16 changed files with 622 additions and 126 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¤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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
49
_primitives/_rust/keisei/src/scope.rs
Normal file
49
_primitives/_rust/keisei/src/scope.rs
Normal 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'"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue