diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0452b..e260b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ follow [SemVer](https://semver.org/spec/v2.0.0.html) with the caveat that pre-1.0 minor bumps may include breaking changes (documented in the release notes). +## v0.8.4 — non-destructive plugin install + global scope + +ADDED + * **Global plugin install.** `kimetsu plugin install --scope global` + installs the Kimetsu surface into the user's home for every session — + `~/.claude/` + `~/.claude.json` (`mcpServers`) for Claude Code, and + `~/.codex/` for Codex — instead of the workspace. `--scope` defaults to + `workspace` (the prior behavior). Also exposed as the `scope` argument on + the `kimetsu_plugin_install` MCP tool. + +FIXED + * **Hook install no longer clobbers existing hooks.** `kimetsu plugin install` + now *merges* its hooks into existing Claude `settings.json` / Codex + `hooks.json` instead of replacing them. Hooks you already have — even on the + same events Kimetsu uses (`UserPromptSubmit`, `PreToolUse`, …) — are + preserved, with Kimetsu's group added alongside. Re-running install is + idempotent (no duplicate groups) and the MCP config + generated docs refresh + without requiring `--force`. + ## v0.8.3 — npm distribution ADDED diff --git a/Cargo.lock b/Cargo.lock index a2b7a27..8d97207 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1483,7 +1483,7 @@ dependencies = [ [[package]] name = "kimetsu-agent" -version = "0.8.3" +version = "0.8.4" dependencies = [ "blake3", "kimetsu-brain", @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "kimetsu-brain" -version = "0.8.3" +version = "0.8.4" dependencies = [ "blake3", "fastembed", @@ -1515,7 +1515,7 @@ dependencies = [ [[package]] name = "kimetsu-chat" -version = "0.8.3" +version = "0.8.4" dependencies = [ "base64 0.22.1", "crossterm", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "kimetsu-cli" -version = "0.8.3" +version = "0.8.4" dependencies = [ "clap", "kimetsu-agent", @@ -1546,7 +1546,7 @@ dependencies = [ [[package]] name = "kimetsu-core" -version = "0.8.3" +version = "0.8.4" dependencies = [ "serde", "serde_json", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "kimetsu-e2e" -version = "0.8.3" +version = "0.8.4" dependencies = [ "kimetsu-agent", "kimetsu-brain", diff --git a/Cargo.toml b/Cargo.toml index b3f545a..4996035 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ resolver = "3" # everything below except the per-crate `description`, `keywords`, # and `categories` which live in each `[package]` block since # crates.io enforces them per crate. -version = "0.8.3" +version = "0.8.4" edition = "2024" # Rust ecosystem dual-license (matches tokio, serde, fastembed-rs, # etc.). UNLICENSED would block crates.io entirely. diff --git a/README.md b/README.md index 81d7e5b..84eaf5c 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,17 @@ cover Claude Code and Codex: ```bash kimetsu plugin install claude --workspace . # writes .mcp.json + .claude/settings.json kimetsu plugin install codex --workspace . # writes .codex/config.toml + .codex/hooks.json + skill + +# Install globally for every project (writes to ~/.claude, ~/.claude.json, ~/.codex): +kimetsu plugin install claude --scope global +kimetsu plugin install codex --scope global ``` +`--scope` defaults to `workspace`. The installer **merges** into existing +config: if you already have hooks — even on the same events Kimetsu uses +(`UserPromptSubmit`, `PreToolUse`, …) — your hooks are kept and Kimetsu's are +added alongside them. Re-running is idempotent and never needs `--force`. + Now your host agent gets the `kimetsu_*` MCP tools (brain context, memory add/list, citations, repo ingest, the cross-harness skill bridge) and starts banking memories across every session. diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index c5645bd..c18b74e 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -64,6 +64,39 @@ impl Default for PluginMode { } } +/// Where the plugin surface is installed: the current workspace +/// (`.claude/`, `.codex/`, `.mcp.json`) or the user's home directory +/// (`~/.claude/`, `~/.claude.json`, `~/.codex/`) for all sessions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum InstallScope { + Workspace, + Global, +} + +impl InstallScope { + pub fn parse(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" | "workspace" | "ws" | "local" | "project" => Ok(Self::Workspace), + "global" | "g" | "user" | "home" => Ok(Self::Global), + other => Err(format!("unknown install scope `{other}`")), + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Workspace => "workspace", + Self::Global => "global", + } + } +} + +impl Default for InstallScope { + fn default() -> Self { + Self::Workspace + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BridgeExtensionManifest { pub id: String, @@ -104,6 +137,7 @@ pub struct BridgeScan { #[derive(Debug, Clone)] pub struct PluginInstallReport { pub target: BridgeTarget, + pub scope: InstallScope, pub mode: PluginMode, pub files: Vec, } @@ -331,26 +365,74 @@ pub fn bridge_sync(workspace: &Path, config: &SkillConfig, force: bool) -> Resul Ok(imported) } +fn resolve_home() -> Result { + std::env::var_os("USERPROFILE") + .filter(|value| !value.is_empty()) + .or_else(|| std::env::var_os("HOME").filter(|value| !value.is_empty())) + .map(PathBuf::from) + .ok_or_else(|| { + "cannot resolve home directory for a global install (set HOME or USERPROFILE)" + .to_string() + }) +} + pub fn plugin_install( workspace: &Path, target: BridgeTarget, + scope: InstallScope, mode: PluginMode, force: bool, proactive: bool, +) -> Result { + let home = match scope { + InstallScope::Global => Some(resolve_home()?), + InstallScope::Workspace => None, + }; + plugin_install_inner( + workspace, + target, + scope, + mode, + force, + proactive, + home.as_deref(), + ) +} + +/// `home` is `Some` for a global install (the directory that stands in for +/// `~`), `None` for a workspace install. Kept separate from `plugin_install` +/// so tests can inject a deterministic home without touching process env. +fn plugin_install_inner( + workspace: &Path, + target: BridgeTarget, + scope: InstallScope, + mode: PluginMode, + force: bool, + proactive: bool, + home: Option<&Path>, ) -> Result { let workspace = normalize_path(workspace); let mut files = Vec::new(); match target { BridgeTarget::ClaudeCode => { - // Claude Code reads project-scoped MCP servers from `.mcp.json` at - // the workspace root, NOT `.claude/mcp.json`. - let mcp = workspace.join(".mcp.json"); - write_mcp_config(&mcp, force)?; + // MCP: workspace -> ./.mcp.json (servers + mcpServers); + // global -> ~/.claude.json (mcpServers only). + let (mcp, only_mcp_servers) = match home { + Some(home) => (home.join(".claude.json"), true), + None => (workspace.join(".mcp.json"), false), + }; + write_mcp_config(&mcp, only_mcp_servers)?; files.push(normalize_path(&mcp)); - let commands = workspace.join(".claude").join("commands").join("kimetsu"); + let claude_dir = match home { + Some(home) => home.join(".claude"), + None => workspace.join(".claude"), + }; + let commands = claude_dir.join("commands").join("kimetsu"); fs::create_dir_all(&commands) .map_err(|err| format!("create {}: {err}", commands.display()))?; + // Generated docs are Kimetsu-owned boilerplate (not user-editable), + // so always overwrite them on install — unlike CLAUDE.md. let bridge = commands.join("bridge.md"); write_text_file( &bridge, @@ -358,7 +440,7 @@ pub fn plugin_install( PluginMode::Optional => CLAUDE_BRIDGE_COMMAND_OPTIONAL, PluginMode::Required => CLAUDE_BRIDGE_COMMAND_REQUIRED, }, - force, + true, )?; files.push(normalize_path(&bridge)); let delegate = commands.join("delegate.md"); @@ -368,21 +450,21 @@ pub fn plugin_install( PluginMode::Optional => CLAUDE_DELEGATE_COMMAND_OPTIONAL, PluginMode::Required => CLAUDE_DELEGATE_COMMAND_REQUIRED, }, - force, + true, )?; files.push(normalize_path(&delegate)); - // Claude Code hooks live in `.claude/settings.json` under the - // `hooks` key (keyed by real events, fed via stdin JSON) — not - // standalone `.ps1`/`.sh` files driven by env vars. - write_claude_settings(&workspace, force, proactive, &mut files)?; + write_claude_settings(&claude_dir, force, proactive, &mut files)?; } BridgeTarget::Codex => { - let config = workspace.join(".codex").join("config.toml"); - write_codex_config(&config, force)?; + let codex_dir = match home { + Some(home) => home.join(".codex"), + None => workspace.join(".codex"), + }; + let config = codex_dir.join("config.toml"); + write_codex_config(&config)?; files.push(normalize_path(&config)); - let skill = workspace - .join(".codex") + let skill = codex_dir .join("skills") .join("kimetsu-bridge") .join("SKILL.md"); @@ -392,12 +474,13 @@ pub fn plugin_install( PluginMode::Optional => CODEX_KIMETSU_SKILL_OPTIONAL, PluginMode::Required => CODEX_KIMETSU_SKILL_REQUIRED, }, - force, + true, )?; files.push(normalize_path(&skill)); - write_codex_hooks(&workspace, force, proactive, &mut files)?; + write_codex_hooks(&codex_dir, proactive, &mut files)?; } BridgeTarget::Kimetsu => { + // Kimetsu extensions are workspace-only; scope is ignored. let dir = workspace.join(".kimetsu").join("extensions"); fs::create_dir_all(&dir).map_err(|err| format!("create {}: {err}", dir.display()))?; files.push(normalize_path(&dir)); @@ -405,6 +488,7 @@ pub fn plugin_install( } Ok(PluginInstallReport { target, + scope, mode, files, }) @@ -414,13 +498,64 @@ pub fn extensions_root(workspace: &Path) -> PathBuf { workspace.join(".kimetsu").join("extensions") } +/// True when a hook matcher-group is one Kimetsu installed (any inner +/// command invokes `kimetsu brain …`). +fn is_kimetsu_hook_group(group: &serde_json::Value) -> bool { + group + .get("hooks") + .and_then(|hooks| hooks.as_array()) + .map(|hooks| { + hooks.iter().any(|hook| { + hook.get("command") + .and_then(|command| command.as_str()) + .is_some_and(|command| command.contains("kimetsu brain")) + }) + }) + .unwrap_or(false) +} + +/// Merge Kimetsu's matcher `group` into the event array at `hooks[event]`, +/// preserving every other group. Idempotent: replaces an existing +/// Kimetsu-owned group instead of appending a duplicate. Never reads or +/// mutates the user's own groups, even when they share Kimetsu's matcher. +fn upsert_kimetsu_hook( + hooks: &mut serde_json::Map, + event: &str, + group: serde_json::Value, +) { + let entry = hooks + .entry(event.to_string()) + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + // If somehow not an array, replace with a fresh single-group array. + let Some(list) = entry.as_array_mut() else { + *entry = serde_json::Value::Array(vec![group]); + return; + }; + match list + .iter_mut() + .find(|existing| is_kimetsu_hook_group(existing)) + { + Some(slot) => *slot = group, + None => list.push(group), + } +} + +/// Merge Kimetsu's `UserPromptSubmit` hook (plus the v0.8 proactive +/// `PreToolUse`/`PostToolUse` Bash hooks when `proactive`) into +/// `.codex/hooks.json`, preserving any other hooks the user has — even on +/// the same events. Idempotent: re-running never duplicates. +/// +/// Codex discovers hooks only from the config-layer `hooks.json` file, using +/// real lifecycle events like `UserPromptSubmit`. The proactive +/// `PreToolUse`/`PostToolUse` hooks use a `Bash` matcher so they fire only +/// around shell invocations; they surface a memory check without blocking the +/// tool call. fn write_codex_hooks( - workspace: &Path, - force: bool, + codex_dir: &Path, proactive: bool, files: &mut Vec, ) -> Result<(), String> { - let hooks = workspace.join(".codex").join("hooks.json"); + let hooks = codex_dir.join("hooks.json"); let mut root = if hooks.is_file() { let text = fs::read_to_string(&hooks).map_err(|err| format!("read {}: {err}", hooks.display()))?; @@ -438,15 +573,11 @@ fn write_codex_hooks( let hooks_obj = hooks_value .as_object_mut() .ok_or_else(|| format!("{} `hooks` must be a JSON object", hooks.display()))?; - if hooks_obj.contains_key("UserPromptSubmit") && !force { - return Err(format!( - "{} already defines UserPromptSubmit hooks; pass --force", - hooks.display() - )); - } - hooks_obj.insert( - "UserPromptSubmit".to_string(), - serde_json::json!([{ + + upsert_kimetsu_hook( + hooks_obj, + "UserPromptSubmit", + serde_json::json!({ "matcher": "", "hooks": [{ "type": "command", @@ -454,15 +585,13 @@ fn write_codex_hooks( "statusMessage": "Loading Kimetsu brain context", "timeout": 30 }] - }]), + }), ); if proactive { - // v0.8: Codex supports PreToolUse/PostToolUse with a regex - // matcher on tool name (Bash). Both surface a relevant memory - // via hookSpecificOutput.additionalContext without blocking. - hooks_obj.insert( - "PreToolUse".to_string(), - serde_json::json!([{ + upsert_kimetsu_hook( + hooks_obj, + "PreToolUse", + serde_json::json!({ "matcher": "Bash", "hooks": [{ "type": "command", @@ -470,11 +599,12 @@ fn write_codex_hooks( "statusMessage": "Kimetsu proactive check", "timeout": 15 }] - }]), + }), ); - hooks_obj.insert( - "PostToolUse".to_string(), - serde_json::json!([{ + upsert_kimetsu_hook( + hooks_obj, + "PostToolUse", + serde_json::json!({ "matcher": "Bash", "hooks": [{ "type": "command", @@ -482,9 +612,10 @@ fn write_codex_hooks( "statusMessage": "Kimetsu proactive check", "timeout": 15 }] - }]), + }), ); } + let text = serde_json::to_string_pretty(&root) .map_err(|err| format!("serialize Codex hooks: {err}"))?; write_text_file(&hooks, &text, true)?; @@ -555,7 +686,11 @@ fn resolve_bridge_skill_source( Ok(registry.resolve_or_manifest_contained(selection)?.root) } -fn write_mcp_config(path: &Path, force: bool) -> Result<(), String> { +/// Upsert the `kimetsu` MCP server into a Claude config file. Idempotent — +/// re-running just rewrites the same entry, preserving all other keys. +/// `only_mcp_servers` is true for `~/.claude.json` (global), which uses +/// only the `mcpServers` key; workspace `.mcp.json` also gets `servers`. +fn write_mcp_config(path: &Path, only_mcp_servers: bool) -> Result<(), String> { let mut root = if path.is_file() { let text = fs::read_to_string(path).map_err(|err| format!("read {}: {err}", path.display()))?; @@ -567,34 +702,20 @@ fn write_mcp_config(path: &Path, force: bool) -> Result<(), String> { let root_obj = root .as_object_mut() .ok_or_else(|| format!("{} must be a JSON object", path.display()))?; - let has_kimetsu = root_obj - .get("servers") - .and_then(|value| value.as_object()) - .map(|map| map.contains_key("kimetsu")) - .unwrap_or(false) - || root_obj - .get("mcpServers") - .and_then(|value| value.as_object()) - .map(|map| map.contains_key("kimetsu")) - .unwrap_or(false); - if has_kimetsu && !force { - return Err(format!( - "{} already has a kimetsu MCP server; pass --force", - path.display() - )); - } let server = serde_json::json!({ - "command": "kimetsu", - "args": ["mcp", "serve", "--workspace", "."] + "command": "kimetsu", + "args": ["mcp", "serve", "--workspace", "."] }); - insert_mcp_server(root_obj, "servers", server.clone(), path)?; + if !only_mcp_servers { + insert_mcp_server(root_obj, "servers", server.clone(), path)?; + } insert_mcp_server(root_obj, "mcpServers", server, path)?; let text = serde_json::to_string_pretty(&root) .map_err(|err| format!("serialize MCP config: {err}"))?; write_text_file(path, &text, true) } -fn write_codex_config(path: &Path, force: bool) -> Result<(), String> { +fn write_codex_config(path: &Path) -> Result<(), String> { let mut root = if path.is_file() { let text = fs::read_to_string(path).map_err(|err| format!("read {}: {err}", path.display()))?; @@ -612,12 +733,6 @@ fn write_codex_config(path: &Path, force: bool) -> Result<(), String> { let servers = servers_value .as_table_mut() .ok_or_else(|| format!("{} `mcp_servers` must be a TOML table", path.display()))?; - if servers.contains_key("kimetsu") && !force { - return Err(format!( - "{} already has a kimetsu MCP server; pass --force", - path.display() - )); - } let mut kimetsu = toml::map::Map::new(); kimetsu.insert( @@ -658,32 +773,34 @@ effort or that you would want to remember next session. /// Write the Claude Code surface that lives under `.claude/`: the brain /// `CLAUDE.md` guidance and the `settings.json` hook registration. fn write_claude_settings( - workspace: &Path, + claude_dir: &Path, force: bool, proactive: bool, files: &mut Vec, ) -> Result<(), String> { - let claude_dir = workspace.join(".claude"); - fs::create_dir_all(&claude_dir) + fs::create_dir_all(claude_dir) .map_err(|err| format!("create {}: {err}", claude_dir.display()))?; - // CLAUDE.md: only seed when missing so we never clobber user edits. + // CLAUDE.md: seed when missing. If it already exists we leave it alone + // unless `force` is set — overwriting an existing CLAUDE.md is the one + // thing `--force` still does. Without force, user edits are never clobbered. let claude_md = claude_dir.join("CLAUDE.md"); - if !claude_md.is_file() { - write_text_file(&claude_md, CLAUDE_MD_CONTENT, force)?; + if !claude_md.is_file() || force { + write_text_file(&claude_md, CLAUDE_MD_CONTENT, true)?; } files.push(normalize_path(&claude_md)); let settings = claude_dir.join("settings.json"); - write_claude_hooks(&settings, force, proactive)?; + write_claude_hooks(&settings, proactive)?; files.push(normalize_path(&settings)); Ok(()) } /// Merge Kimetsu's `UserPromptSubmit`/`Stop` hooks (plus the v0.8 /// proactive `PreToolUse`/`PostToolUse` Bash hooks when `proactive`) -/// into `.claude/settings.json`, preserving any other settings. -fn write_claude_hooks(path: &Path, force: bool, proactive: bool) -> Result<(), String> { +/// into `settings.json`, preserving any other hooks the user has — even +/// on the same events. Idempotent: re-running never duplicates. +fn write_claude_hooks(path: &Path, proactive: bool) -> Result<(), String> { let mut root = if path.is_file() { let text = fs::read_to_string(path).map_err(|err| format!("read {}: {err}", path.display()))?; @@ -695,39 +812,48 @@ fn write_claude_hooks(path: &Path, force: bool, proactive: bool) -> Result<(), S let root_obj = root .as_object_mut() .ok_or_else(|| format!("{} must be a JSON object", path.display()))?; - if root_obj.contains_key("hooks") && !force { - return Err(format!( - "{} already defines hooks; pass --force", - path.display() - )); - } - let mut hooks = serde_json::json!({ - "UserPromptSubmit": [{ + let hooks_value = root_obj + .entry("hooks".to_string()) + .or_insert_with(|| serde_json::json!({})); + let hooks_obj = hooks_value + .as_object_mut() + .ok_or_else(|| format!("{} `hooks` must be a JSON object", path.display()))?; + + upsert_kimetsu_hook( + hooks_obj, + "UserPromptSubmit", + serde_json::json!({ "matcher": "", "hooks": [{ "type": "command", "command": "kimetsu brain context-hook" }] - }], - "Stop": [{ + }), + ); + upsert_kimetsu_hook( + hooks_obj, + "Stop", + serde_json::json!({ "matcher": "", "hooks": [{ "type": "command", "command": "kimetsu brain stop-hook" }] - }] - }); - if proactive && let Some(map) = hooks.as_object_mut() { - map.insert( - "PreToolUse".to_string(), - serde_json::json!([{ + }), + ); + if proactive { + upsert_kimetsu_hook( + hooks_obj, + "PreToolUse", + serde_json::json!({ "matcher": "Bash", "hooks": [{ "type": "command", "command": "kimetsu brain pretool-hook" }] - }]), + }), ); - map.insert( - "PostToolUse".to_string(), - serde_json::json!([{ + upsert_kimetsu_hook( + hooks_obj, + "PostToolUse", + serde_json::json!({ "matcher": "Bash", "hooks": [{ "type": "command", "command": "kimetsu brain posttool-hook" }] - }]), + }), ); } - root_obj.insert("hooks".to_string(), hooks); + let text = serde_json::to_string_pretty(&root) .map_err(|err| format!("serialize Claude settings: {err}"))?; write_text_file(path, &text, true) @@ -856,6 +982,63 @@ fn slugify(name: &str) -> String { #[cfg(test)] mod tests { use super::*; + use serde_json::json; + + #[test] + fn upsert_kimetsu_hook_preserves_user_groups_and_is_idempotent() { + // A user already has their own UserPromptSubmit hook. + let mut hooks: serde_json::Map = serde_json::from_value(json!({ + "UserPromptSubmit": [ + { "matcher": "", "hooks": [{ "type": "command", "command": "my-own-hook" }] } + ] + })) + .unwrap(); + + let km = json!({ + "matcher": "", + "hooks": [{ "type": "command", "command": "kimetsu brain context-hook" }] + }); + + // First upsert: append alongside the user's group. + upsert_kimetsu_hook(&mut hooks, "UserPromptSubmit", km.clone()); + let arr = hooks["UserPromptSubmit"].as_array().unwrap(); + assert_eq!(arr.len(), 2, "kimetsu group appended, user group kept"); + assert_eq!(arr[0]["hooks"][0]["command"], "my-own-hook"); + assert_eq!(arr[1]["hooks"][0]["command"], "kimetsu brain context-hook"); + + // Second upsert (re-run): replace in place, no duplicate. + upsert_kimetsu_hook(&mut hooks, "UserPromptSubmit", km); + let arr = hooks["UserPromptSubmit"].as_array().unwrap(); + assert_eq!( + arr.len(), + 2, + "re-run is idempotent, no duplicate kimetsu group" + ); + assert_eq!(arr[0]["hooks"][0]["command"], "my-own-hook"); + + // New event with no prior array: creates it. + let km_stop = json!({ "matcher": "", "hooks": [{ "type": "command", "command": "kimetsu brain stop-hook" }] }); + upsert_kimetsu_hook(&mut hooks, "Stop", km_stop); + assert_eq!(hooks["Stop"].as_array().unwrap().len(), 1); + } + + #[test] + fn install_scope_parses_aliases() { + assert_eq!(InstallScope::parse("").unwrap(), InstallScope::Workspace); + assert_eq!( + InstallScope::parse("workspace").unwrap(), + InstallScope::Workspace + ); + assert_eq!( + InstallScope::parse("Local").unwrap(), + InstallScope::Workspace + ); + assert_eq!(InstallScope::parse("global").unwrap(), InstallScope::Global); + assert_eq!(InstallScope::parse("USER").unwrap(), InstallScope::Global); + assert_eq!(InstallScope::Workspace.as_str(), "workspace"); + assert_eq!(InstallScope::Global.as_str(), "global"); + assert!(InstallScope::parse("nope").is_err()); + } #[test] fn imports_and_exports_skill_bundle() { @@ -890,6 +1073,7 @@ mod tests { let optional = plugin_install( &root, BridgeTarget::Codex, + InstallScope::Workspace, PluginMode::Optional, false, true, @@ -928,8 +1112,15 @@ mod tests { assert!(!root.join(".codex/mcp.json").exists()); assert!(!root.join(".codex/hooks/pre-turn.ps1").exists()); - let required = plugin_install(&root, BridgeTarget::Codex, PluginMode::Required, true, true) - .expect("required install"); + let required = plugin_install( + &root, + BridgeTarget::Codex, + InstallScope::Workspace, + PluginMode::Required, + true, + true, + ) + .expect("required install"); assert_eq!(required.mode, PluginMode::Required); let required_text = fs::read_to_string(&skill_path).expect("required skill"); assert!(required_text.contains("Required mode")); @@ -953,6 +1144,7 @@ mod tests { plugin_install( &root, BridgeTarget::Codex, + InstallScope::Workspace, PluginMode::Optional, false, false, @@ -969,6 +1161,218 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn claude_hooks_merge_preserves_user_hooks() { + let root = temp_root("claude_hooks_merge"); + let claude = root.join(".claude"); + fs::create_dir_all(&claude).unwrap(); + // User already has their own UserPromptSubmit hook and an unrelated event. + fs::write( + claude.join("settings.json"), + serde_json::to_string_pretty(&json!({ + "hooks": { + "UserPromptSubmit": [ + { "matcher": "", "hooks": [{ "type": "command", "command": "user-prompt-thing" }] } + ], + "SubagentStop": [ + { "matcher": "", "hooks": [{ "type": "command", "command": "user-subagent-thing" }] } + ] + } + })) + .unwrap(), + ) + .unwrap(); + + let settings = claude.join("settings.json"); + write_claude_hooks(&settings, true).unwrap(); + // Re-run to prove idempotency. + write_claude_hooks(&settings, true).unwrap(); + + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + let ups = value["hooks"]["UserPromptSubmit"].as_array().unwrap(); + assert_eq!( + ups.len(), + 2, + "user group kept + one kimetsu group, no dupes" + ); + assert_eq!(ups[0]["hooks"][0]["command"], "user-prompt-thing"); + assert_eq!(ups[1]["hooks"][0]["command"], "kimetsu brain context-hook"); + // Unrelated user event untouched. + assert_eq!( + value["hooks"]["SubagentStop"][0]["hooks"][0]["command"], + "user-subagent-thing" + ); + // Kimetsu's own events present. + assert_eq!( + value["hooks"]["Stop"][0]["hooks"][0]["command"], + "kimetsu brain stop-hook" + ); + assert_eq!( + value["hooks"]["PreToolUse"][0]["hooks"][0]["command"], + "kimetsu brain pretool-hook" + ); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn codex_hooks_merge_preserves_user_hooks() { + let root = temp_root("codex_hooks_merge"); + let codex = root.join(".codex"); + fs::create_dir_all(&codex).unwrap(); + fs::write( + codex.join("hooks.json"), + serde_json::to_string_pretty(&json!({ + "hooks": { + "UserPromptSubmit": [ + { "matcher": "", "hooks": [{ "type": "command", "command": "user-codex-hook" }] } + ] + } + })) + .unwrap(), + ) + .unwrap(); + + let mut files = Vec::new(); + write_codex_hooks(&codex, true, &mut files).unwrap(); + write_codex_hooks(&codex, true, &mut files).unwrap(); // idempotent + + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(codex.join("hooks.json")).unwrap()).unwrap(); + let ups = value["hooks"]["UserPromptSubmit"].as_array().unwrap(); + assert_eq!(ups.len(), 2, "user group kept + one kimetsu group"); + assert_eq!(ups[0]["hooks"][0]["command"], "user-codex-hook"); + assert_eq!( + ups[1]["hooks"][0]["command"], + "kimetsu brain context-hook --workspace ." + ); + assert_eq!( + value["hooks"]["PreToolUse"][0]["hooks"][0]["command"], + "kimetsu brain pretool-hook --workspace ." + ); + assert_eq!( + value["hooks"]["PostToolUse"][0]["hooks"][0]["command"], + "kimetsu brain posttool-hook --workspace ." + ); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn mcp_config_is_idempotent_and_scopes_keys() { + let root = temp_root("mcp_idempotent"); + let mcp = root.join(".mcp.json"); + + // Workspace style: write both `servers` and `mcpServers`, twice, no error. + write_mcp_config(&mcp, false).unwrap(); + write_mcp_config(&mcp, false).unwrap(); + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); + assert_eq!(value["servers"]["kimetsu"]["command"], "kimetsu"); + assert_eq!(value["mcpServers"]["kimetsu"]["command"], "kimetsu"); + + // Global style: only `mcpServers`, preserving unrelated keys. + let claude_json = root.join(".claude.json"); + fs::write( + &claude_json, + serde_json::to_string(&json!({ "keepme": 1 })).unwrap(), + ) + .unwrap(); + write_mcp_config(&claude_json, true).unwrap(); + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&claude_json).unwrap()).unwrap(); + assert_eq!(value["keepme"], 1); + assert_eq!(value["mcpServers"]["kimetsu"]["command"], "kimetsu"); + assert!( + value.get("servers").is_none(), + "global writes mcpServers only" + ); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn plugin_install_refreshes_generated_files_without_force() { + let root = temp_root("plugin_install_refresh"); + // First install (Codex) writes SKILL.md. + plugin_install( + &root, + BridgeTarget::Codex, + InstallScope::Workspace, + PluginMode::Optional, + false, + true, + ) + .unwrap(); + // Second install with force=false must succeed (refresh, not error). + plugin_install( + &root, + BridgeTarget::Codex, + InstallScope::Workspace, + PluginMode::Required, + false, + true, + ) + .unwrap(); + + let skill = fs::read_to_string(root.join(".codex/skills/kimetsu-bridge/SKILL.md")).unwrap(); + // Prove the file was overwritten with the Required variant, not left as Optional. + assert!( + skill.contains("Treat missing Kimetsu MCP access as a setup blocker"), + "SKILL.md should contain Required-mode wording after second install" + ); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn plugin_install_global_writes_to_home_not_workspace() { + let ws = temp_root("plugin_install_global_ws"); + let home = temp_root("plugin_install_global_home"); + + // Claude global install into the injected home. + plugin_install_inner( + &ws, + BridgeTarget::ClaudeCode, + InstallScope::Global, + PluginMode::Optional, + false, + true, + Some(home.as_path()), + ) + .unwrap(); + + assert!(home.join(".claude/settings.json").is_file()); + assert!(home.join(".claude/CLAUDE.md").is_file()); + assert!(home.join(".claude/commands/kimetsu/bridge.md").is_file()); + assert!(home.join(".claude.json").is_file()); + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(home.join(".claude.json")).unwrap()).unwrap(); + assert_eq!(value["mcpServers"]["kimetsu"]["command"], "kimetsu"); + assert!(value.get("servers").is_none()); + assert!(!ws.join(".claude").exists()); + assert!(!ws.join(".mcp.json").exists()); + + // Codex global install. + plugin_install_inner( + &ws, + BridgeTarget::Codex, + InstallScope::Global, + PluginMode::Optional, + false, + true, + Some(home.as_path()), + ) + .unwrap(); + assert!(home.join(".codex/config.toml").is_file()); + assert!(home.join(".codex/hooks.json").is_file()); + assert!(!ws.join(".codex").exists()); + + fs::remove_dir_all(ws).ok(); + fs::remove_dir_all(home).ok(); + } + fn temp_root(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/kimetsu-chat/src/lib.rs b/crates/kimetsu-chat/src/lib.rs index 9d13fc1..863b1cf 100644 --- a/crates/kimetsu-chat/src/lib.rs +++ b/crates/kimetsu-chat/src/lib.rs @@ -31,8 +31,8 @@ pub mod skills; pub mod ui; pub use bridge::{ - BridgeTarget, PluginMode, bridge_export_skill, bridge_import_skill, bridge_scan, bridge_sync, - plugin_install, + BridgeTarget, InstallScope, PluginMode, bridge_export_skill, bridge_import_skill, bridge_scan, + bridge_sync, plugin_install, }; pub use commands::SlashCommand; pub use cost::CostMeter; diff --git a/crates/kimetsu-chat/src/mcp_server.rs b/crates/kimetsu-chat/src/mcp_server.rs index 642bb47..80ea6e8 100644 --- a/crates/kimetsu-chat/src/mcp_server.rs +++ b/crates/kimetsu-chat/src/mcp_server.rs @@ -7,8 +7,8 @@ use kimetsu_core::memory::{MemoryKind, MemoryScope}; use serde_json::{Value, json}; use crate::bridge::{ - BridgeTarget, PluginMode, bridge_export_skill, bridge_import_skill, bridge_scan, bridge_sync, - plugin_install, + BridgeTarget, InstallScope, PluginMode, bridge_export_skill, bridge_import_skill, bridge_scan, + bridge_sync, plugin_install, }; use crate::skills::{SkillConfig, SkillRegistry, skill_origin_label}; @@ -60,7 +60,7 @@ const BRIDGE_EXPORT_DESCRIPTION: &str = "Export a canonical or discovered skill const BRIDGE_SYNC_DESCRIPTION: &str = "Bulk-import all discovered non-Kimetsu skills into .kimetsu/extensions. Use for setup or migration, not during a narrow task unless the user asked to synchronize capabilities. This writes files and may touch many skill bundles."; -const PLUGIN_INSTALL_DESCRIPTION: &str = "Install Kimetsu MCP/plugin wiring for a target harness in this workspace. For codex, writes .codex/config.toml, .codex/hooks.json, and the kimetsu-bridge skill; for claude-code, writes .mcp.json, command docs, and .claude/settings.json hooks. Set mode=optional to recommend brain-first usage, or mode=required to tell the host harness that non-trivial work must load Kimetsu brain context. Installed guidance tells benchmark agents to prefer kimetsu_benchmark_context and record outcomes through kimetsu_benchmark_record_outcome."; +const PLUGIN_INSTALL_DESCRIPTION: &str = "Install Kimetsu MCP/plugin wiring for a target harness in this workspace. For codex, writes .codex/config.toml, .codex/hooks.json, and the kimetsu-bridge skill; for claude-code, writes .mcp.json, command docs, and .claude/settings.json hooks. Set mode=optional to recommend brain-first usage, or mode=required to tell the host harness that non-trivial work must load Kimetsu brain context. Installed guidance tells benchmark agents to prefer kimetsu_benchmark_context and record outcomes through kimetsu_benchmark_record_outcome. Set scope=workspace (default) to install into this workspace, or scope=global to install into the user's home (~/.claude, ~/.claude.json, ~/.codex) for all sessions. Existing user hooks are preserved (merged, not replaced)."; #[derive(Debug, Clone)] pub struct McpServeConfig { @@ -336,6 +336,12 @@ fn call_tool( } "kimetsu_plugin_install" => { let target = BridgeTarget::parse(&string_arg(&arguments, "target")?)?; + let scope = arguments + .get("scope") + .and_then(Value::as_str) + .map(InstallScope::parse) + .transpose()? + .unwrap_or_default(); let mode = arguments .get("mode") .and_then(Value::as_str) @@ -352,9 +358,10 @@ fn call_tool( .get("proactive") .and_then(Value::as_bool) .unwrap_or(true); - let report = plugin_install(workspace, target, mode, force, proactive)?; + let report = plugin_install(workspace, target, scope, mode, force, proactive)?; Ok(json!({ "target": report.target.as_str(), + "scope": report.scope.as_str(), "mode": report.mode.as_str(), "files": report.files, })) @@ -1764,6 +1771,11 @@ fn tool_definitions() -> Value { "type": "object", "properties": { "target": { "type": "string", "enum": ["claude-code", "codex"] }, + "scope": { + "type": "string", + "enum": ["workspace", "global"], + "description": "workspace (default) installs into this workspace's .claude/.codex; global installs into ~/.claude(.json) and ~/.codex for all sessions." + }, "mode": { "type": "string", "enum": ["optional", "required"], diff --git a/crates/kimetsu-cli/src/main.rs b/crates/kimetsu-cli/src/main.rs index 8c07003..17460d6 100644 --- a/crates/kimetsu-cli/src/main.rs +++ b/crates/kimetsu-cli/src/main.rs @@ -277,6 +277,11 @@ struct PluginInstallArgs { /// required treats missing brain context as a setup blocker for broad work. #[arg(long, default_value = "optional")] mode: String, + /// Install scope: `workspace` (default) writes .claude/.codex in the + /// workspace; `global` writes to ~/.claude(.json) and ~/.codex for all + /// sessions. + #[arg(long, default_value = "workspace")] + scope: String, #[arg(long)] force: bool, /// v0.8: skip wiring the proactive PreToolUse/PostToolUse Bash @@ -934,20 +939,30 @@ fn mcp(command: McpCommand) -> KimetsuResult<()> { } fn plugin(command: PluginCommand) -> KimetsuResult<()> { - use kimetsu_chat::{BridgeTarget, PluginMode, plugin_install}; + use kimetsu_chat::{BridgeTarget, InstallScope, PluginMode, plugin_install}; match command { PluginCommand::Install(args) => { let workspace = args.workspace.canonicalize()?; let target = BridgeTarget::parse(&args.target) .map_err(|err| format!("kimetsu plugin install: {err}"))?; - let mode = PluginMode::parse(&args.mode) + let scope = InstallScope::parse(&args.scope) .map_err(|err| format!("kimetsu plugin install: {err}"))?; - let report = plugin_install(&workspace, target, mode, args.force, !args.no_proactive) + let mode = PluginMode::parse(&args.mode) .map_err(|err| format!("kimetsu plugin install: {err}"))?; + let report = plugin_install( + &workspace, + target, + scope, + mode, + args.force, + !args.no_proactive, + ) + .map_err(|err| format!("kimetsu plugin install: {err}"))?; println!( - "installed Kimetsu plugin surface for {} in {} mode", + "installed Kimetsu plugin surface for {} ({} scope) in {} mode", report.target.as_str(), + report.scope.as_str(), report.mode.as_str() ); for file in report.files { diff --git a/docs/superpowers/plans/2026-06-02-plugin-install-merge-and-scope.md b/docs/superpowers/plans/2026-06-02-plugin-install-merge-and-scope.md new file mode 100644 index 0000000..7246f70 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-plugin-install-merge-and-scope.md @@ -0,0 +1,1220 @@ +# Plugin Install: Hook Merge + Workspace/Global Scope — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `kimetsu plugin install` merge its hooks into existing hook configs (never destroying user hooks, including on events Kimetsu also uses), be idempotent on re-run, and support a `--scope workspace|global` choice for both Claude Code and Codex. + +**Architecture:** All install logic lives in `crates/kimetsu-chat/src/bridge.rs`. A shared idempotent merge helper upserts Kimetsu's own matcher group into each event array. Scope is resolved up front into concrete target paths (workspace dir vs. home dir), threaded through `plugin_install` and exposed on the CLI (`--scope`) and the `kimetsu_plugin_install` MCP tool. + +**Tech Stack:** Rust, `serde_json` (Claude `settings.json` / `.mcp.json` / `~/.claude.json`), `toml` (Codex `config.toml`), `clap` (CLI), the workspace's MCP server harness. + +**Spec:** `docs/superpowers/specs/2026-06-02-plugin-install-merge-and-scope-design.md` + +--- + +## File Map + +- **Modify** `crates/kimetsu-chat/src/bridge.rs` — the install engine: new `InstallScope` enum, shared hook-merge helper, idempotent writers, scope-aware path resolution, `plugin_install` signature, unit tests. +- **Modify** `crates/kimetsu-chat/src/lib.rs:33-36` — re-export `InstallScope`. +- **Modify** `crates/kimetsu-cli/src/main.rs` — `--scope` arg on `PluginInstallArgs`, parse + pass through, print scope. +- **Modify** `crates/kimetsu-chat/src/mcp_server.rs` — `scope` arg in the `kimetsu_plugin_install` handler, output, and input schema/description. + +Tasks 1–6 keep the `plugin_install` signature unchanged (each commit compiles & tests pass). Task 7 is the integration task that introduces `scope` across `bridge.rs` + `main.rs` + `mcp_server.rs` atomically. + +--- + +## Task 1: `InstallScope` enum + +**Files:** +- Modify: `crates/kimetsu-chat/src/bridge.rs:37-65` (insert after the `PluginMode` block) +- Modify: `crates/kimetsu-chat/src/lib.rs:33-36` +- Test: `crates/kimetsu-chat/src/bridge.rs` (tests module) + +- [ ] **Step 1: Write the failing test** + +Add to the `mod tests` block in `bridge.rs` (just below `use super::*;`): + +```rust +#[test] +fn install_scope_parses_aliases() { + assert_eq!(InstallScope::parse("").unwrap(), InstallScope::Workspace); + assert_eq!(InstallScope::parse("workspace").unwrap(), InstallScope::Workspace); + assert_eq!(InstallScope::parse("Local").unwrap(), InstallScope::Workspace); + assert_eq!(InstallScope::parse("global").unwrap(), InstallScope::Global); + assert_eq!(InstallScope::parse("USER").unwrap(), InstallScope::Global); + assert_eq!(InstallScope::Workspace.as_str(), "workspace"); + assert_eq!(InstallScope::Global.as_str(), "global"); + assert!(InstallScope::parse("nope").is_err()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p kimetsu-chat install_scope_parses_aliases` +Expected: FAIL — `cannot find type InstallScope in this scope`. + +- [ ] **Step 3: Add the enum** + +Insert in `bridge.rs` immediately after the `impl Default for PluginMode { … }` block (after line 65): + +```rust +/// Where the plugin surface is installed: the current workspace +/// (`.claude/`, `.codex/`, `.mcp.json`) or the user's home directory +/// (`~/.claude/`, `~/.claude.json`, `~/.codex/`) for all sessions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum InstallScope { + Workspace, + Global, +} + +impl InstallScope { + pub fn parse(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" | "workspace" | "ws" | "local" | "project" => Ok(Self::Workspace), + "global" | "g" | "user" | "home" => Ok(Self::Global), + other => Err(format!("unknown install scope `{other}`")), + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Workspace => "workspace", + Self::Global => "global", + } + } +} + +impl Default for InstallScope { + fn default() -> Self { + Self::Workspace + } +} +``` + +- [ ] **Step 4: Re-export from lib.rs** + +In `crates/kimetsu-chat/src/lib.rs`, change the `pub use bridge::{…}` block (lines 33-36) to add `InstallScope`: + +```rust +pub use bridge::{ + BridgeTarget, InstallScope, PluginMode, bridge_export_skill, bridge_import_skill, bridge_scan, + bridge_sync, plugin_install, +}; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cargo test -p kimetsu-chat install_scope_parses_aliases` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/kimetsu-chat/src/bridge.rs crates/kimetsu-chat/src/lib.rs +git commit -m "feat: add InstallScope enum for plugin install scope" +``` + +--- + +## Task 2: Shared idempotent hook-merge helper + +**Files:** +- Modify: `crates/kimetsu-chat/src/bridge.rs` (add private helpers near the other hook writers, e.g. just above `write_codex_hooks` at line 417) +- Test: `crates/kimetsu-chat/src/bridge.rs` (tests module) + +This helper is the heart of the fix: upsert Kimetsu's group into one event array without touching anything else. + +- [ ] **Step 1: Write the failing test** + +Add to `mod tests`: + +```rust +#[test] +fn upsert_kimetsu_hook_preserves_user_groups_and_is_idempotent() { + // A user already has their own UserPromptSubmit hook. + let mut hooks: serde_json::Map = serde_json::from_value(json!({ + "UserPromptSubmit": [ + { "matcher": "", "hooks": [{ "type": "command", "command": "my-own-hook" }] } + ] + })) + .unwrap(); + + let km = json!({ + "matcher": "", + "hooks": [{ "type": "command", "command": "kimetsu brain context-hook" }] + }); + + // First upsert: append alongside the user's group. + upsert_kimetsu_hook(&mut hooks, "UserPromptSubmit", km.clone()); + let arr = hooks["UserPromptSubmit"].as_array().unwrap(); + assert_eq!(arr.len(), 2, "kimetsu group appended, user group kept"); + assert_eq!(arr[0]["hooks"][0]["command"], "my-own-hook"); + assert_eq!(arr[1]["hooks"][0]["command"], "kimetsu brain context-hook"); + + // Second upsert (re-run): replace in place, no duplicate. + upsert_kimetsu_hook(&mut hooks, "UserPromptSubmit", km); + let arr = hooks["UserPromptSubmit"].as_array().unwrap(); + assert_eq!(arr.len(), 2, "re-run is idempotent, no duplicate kimetsu group"); + assert_eq!(arr[0]["hooks"][0]["command"], "my-own-hook"); + + // New event with no prior array: creates it. + let km_stop = json!({ "matcher": "", "hooks": [{ "type": "command", "command": "kimetsu brain stop-hook" }] }); + upsert_kimetsu_hook(&mut hooks, "Stop", km_stop); + assert_eq!(hooks["Stop"].as_array().unwrap().len(), 1); +} +``` + +Add `use serde_json::json;` is not needed — the `json!` macro is in scope via `serde_json::json!`; use the fully-qualified `serde_json::json!` in the test, or add `use serde_json::json;` at the top of `mod tests`. Add this line under `use super::*;`: + +```rust +use serde_json::json; +``` + +Add this `use` exactly once — the test code in Tasks 3, 4, and 5 also uses the `json!` macro and relies on this single import in the shared `mod tests`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p kimetsu-chat upsert_kimetsu_hook_preserves_user_groups_and_is_idempotent` +Expected: FAIL — `cannot find function upsert_kimetsu_hook`. + +- [ ] **Step 3: Add the helpers** + +Insert in `bridge.rs` just above `fn write_codex_hooks` (line 417): + +```rust +/// True when a hook matcher-group is one Kimetsu installed (any inner +/// command invokes `kimetsu brain …`). +fn is_kimetsu_hook_group(group: &serde_json::Value) -> bool { + group + .get("hooks") + .and_then(|hooks| hooks.as_array()) + .map(|hooks| { + hooks.iter().any(|hook| { + hook.get("command") + .and_then(|command| command.as_str()) + .is_some_and(|command| command.contains("kimetsu brain")) + }) + }) + .unwrap_or(false) +} + +/// Merge Kimetsu's matcher `group` into the event array at `hooks[event]`, +/// preserving every other group. Idempotent: replaces an existing +/// Kimetsu-owned group instead of appending a duplicate. Never reads or +/// mutates the user's own groups, even when they share Kimetsu's matcher. +fn upsert_kimetsu_hook( + hooks: &mut serde_json::Map, + event: &str, + group: serde_json::Value, +) { + let entry = hooks + .entry(event.to_string()) + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + // If somehow not an array, replace with a fresh single-group array. + let Some(list) = entry.as_array_mut() else { + *entry = serde_json::Value::Array(vec![group]); + return; + }; + match list.iter_mut().find(|existing| is_kimetsu_hook_group(existing)) { + Some(slot) => *slot = group, + None => list.push(group), + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p kimetsu-chat upsert_kimetsu_hook_preserves_user_groups_and_is_idempotent` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/kimetsu-chat/src/bridge.rs +git commit -m "feat: add idempotent kimetsu hook-merge helper" +``` + +--- + +## Task 3: Claude hooks writer uses the merge helper + +**Files:** +- Modify: `crates/kimetsu-chat/src/bridge.rs:686-734` (`write_claude_hooks`) and its caller at line 678 +- Test: `crates/kimetsu-chat/src/bridge.rs` (tests module) + +- [ ] **Step 1: Write the failing test** + +Add to `mod tests`: + +```rust +#[test] +fn claude_hooks_merge_preserves_user_hooks() { + let root = temp_root("claude_hooks_merge"); + let claude = root.join(".claude"); + fs::create_dir_all(&claude).unwrap(); + // User already has their own UserPromptSubmit hook and an unrelated event. + fs::write( + claude.join("settings.json"), + serde_json::to_string_pretty(&json!({ + "hooks": { + "UserPromptSubmit": [ + { "matcher": "", "hooks": [{ "type": "command", "command": "user-prompt-thing" }] } + ], + "SubagentStop": [ + { "matcher": "", "hooks": [{ "type": "command", "command": "user-subagent-thing" }] } + ] + } + })) + .unwrap(), + ) + .unwrap(); + + let settings = claude.join("settings.json"); + write_claude_hooks(&settings, true).unwrap(); + // Re-run to prove idempotency. + write_claude_hooks(&settings, true).unwrap(); + + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + let ups = value["hooks"]["UserPromptSubmit"].as_array().unwrap(); + assert_eq!(ups.len(), 2, "user group kept + one kimetsu group, no dupes"); + assert_eq!(ups[0]["hooks"][0]["command"], "user-prompt-thing"); + assert_eq!(ups[1]["hooks"][0]["command"], "kimetsu brain context-hook"); + // Unrelated user event untouched. + assert_eq!( + value["hooks"]["SubagentStop"][0]["hooks"][0]["command"], + "user-subagent-thing" + ); + // Kimetsu's own events present. + assert_eq!(value["hooks"]["Stop"][0]["hooks"][0]["command"], "kimetsu brain stop-hook"); + assert_eq!(value["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "kimetsu brain pretool-hook"); + + fs::remove_dir_all(root).ok(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p kimetsu-chat claude_hooks_merge_preserves_user_hooks` +Expected: FAIL — current `write_claude_hooks` takes `(path, force, proactive)` (arity mismatch) and replaces the whole `hooks` object. + +- [ ] **Step 3: Rewrite `write_claude_hooks`** + +Replace the entire function body (lines 686-734) with: + +```rust +/// Merge Kimetsu's `UserPromptSubmit`/`Stop` hooks (plus the v0.8 +/// proactive `PreToolUse`/`PostToolUse` Bash hooks when `proactive`) +/// into `settings.json`, preserving any other hooks the user has — even +/// on the same events. Idempotent: re-running never duplicates. +fn write_claude_hooks(path: &Path, proactive: bool) -> Result<(), String> { + let mut root = if path.is_file() { + let text = + fs::read_to_string(path).map_err(|err| format!("read {}: {err}", path.display()))?; + serde_json::from_str::(&text) + .map_err(|err| format!("parse {}: {err}", path.display()))? + } else { + serde_json::json!({}) + }; + let root_obj = root + .as_object_mut() + .ok_or_else(|| format!("{} must be a JSON object", path.display()))?; + let hooks_value = root_obj + .entry("hooks".to_string()) + .or_insert_with(|| serde_json::json!({})); + let hooks_obj = hooks_value + .as_object_mut() + .ok_or_else(|| format!("{} `hooks` must be a JSON object", path.display()))?; + + upsert_kimetsu_hook( + hooks_obj, + "UserPromptSubmit", + serde_json::json!({ + "matcher": "", + "hooks": [{ "type": "command", "command": "kimetsu brain context-hook" }] + }), + ); + upsert_kimetsu_hook( + hooks_obj, + "Stop", + serde_json::json!({ + "matcher": "", + "hooks": [{ "type": "command", "command": "kimetsu brain stop-hook" }] + }), + ); + if proactive { + upsert_kimetsu_hook( + hooks_obj, + "PreToolUse", + serde_json::json!({ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "kimetsu brain pretool-hook" }] + }), + ); + upsert_kimetsu_hook( + hooks_obj, + "PostToolUse", + serde_json::json!({ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "kimetsu brain posttool-hook" }] + }), + ); + } + + let text = serde_json::to_string_pretty(&root) + .map_err(|err| format!("serialize Claude settings: {err}"))?; + write_text_file(path, &text, true) +} +``` + +- [ ] **Step 4: Update the caller** + +In `write_claude_settings` (line 678), change the call to drop the `force` argument: + +```rust + let settings = claude_dir.join("settings.json"); + write_claude_hooks(&settings, proactive)?; + files.push(normalize_path(&settings)); +``` + +(Note: `claude_dir` is still `workspace.join(".claude")` at this point — Task 7 reworks the surrounding function. Only the `write_claude_hooks` call line changes here.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test -p kimetsu-chat claude_hooks_merge_preserves_user_hooks` +Expected: PASS. + +Run: `cargo test -p kimetsu-chat` +Expected: PASS (existing tests still green). + +- [ ] **Step 6: Commit** + +```bash +git add crates/kimetsu-chat/src/bridge.rs +git commit -m "fix: merge Claude hooks instead of replacing user config" +``` + +--- + +## Task 4: Codex hooks writer uses the merge helper + takes a `.codex` dir + +**Files:** +- Modify: `crates/kimetsu-chat/src/bridge.rs:417-493` (`write_codex_hooks`) and its caller at line 398 +- Test: `crates/kimetsu-chat/src/bridge.rs` (tests module) + +- [ ] **Step 1: Write the failing test** + +Add to `mod tests`: + +```rust +#[test] +fn codex_hooks_merge_preserves_user_hooks() { + let root = temp_root("codex_hooks_merge"); + let codex = root.join(".codex"); + fs::create_dir_all(&codex).unwrap(); + fs::write( + codex.join("hooks.json"), + serde_json::to_string_pretty(&json!({ + "hooks": { + "UserPromptSubmit": [ + { "matcher": "", "hooks": [{ "type": "command", "command": "user-codex-hook" }] } + ] + } + })) + .unwrap(), + ) + .unwrap(); + + let mut files = Vec::new(); + write_codex_hooks(&codex, true, &mut files).unwrap(); + write_codex_hooks(&codex, true, &mut files).unwrap(); // idempotent + + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(codex.join("hooks.json")).unwrap()).unwrap(); + let ups = value["hooks"]["UserPromptSubmit"].as_array().unwrap(); + assert_eq!(ups.len(), 2, "user group kept + one kimetsu group"); + assert_eq!(ups[0]["hooks"][0]["command"], "user-codex-hook"); + assert_eq!( + ups[1]["hooks"][0]["command"], + "kimetsu brain context-hook --workspace ." + ); + assert_eq!( + value["hooks"]["PreToolUse"][0]["hooks"][0]["command"], + "kimetsu brain pretool-hook --workspace ." + ); + + fs::remove_dir_all(root).ok(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p kimetsu-chat codex_hooks_merge_preserves_user_hooks` +Expected: FAIL — current `write_codex_hooks` takes `(workspace, force, proactive, files)` (arity mismatch) and overwrites the `UserPromptSubmit` array. + +- [ ] **Step 3: Rewrite `write_codex_hooks`** + +Replace the entire function (lines 417-493) with this version — it takes the `.codex` directory directly, drops `force`, and merges: + +```rust +fn write_codex_hooks( + codex_dir: &Path, + proactive: bool, + files: &mut Vec, +) -> Result<(), String> { + let hooks = codex_dir.join("hooks.json"); + let mut root = if hooks.is_file() { + let text = + fs::read_to_string(&hooks).map_err(|err| format!("read {}: {err}", hooks.display()))?; + serde_json::from_str::(&text) + .map_err(|err| format!("parse {}: {err}", hooks.display()))? + } else { + serde_json::json!({}) + }; + let root_obj = root + .as_object_mut() + .ok_or_else(|| format!("{} must be a JSON object", hooks.display()))?; + let hooks_value = root_obj + .entry("hooks".to_string()) + .or_insert_with(|| serde_json::json!({})); + let hooks_obj = hooks_value + .as_object_mut() + .ok_or_else(|| format!("{} `hooks` must be a JSON object", hooks.display()))?; + + upsert_kimetsu_hook( + hooks_obj, + "UserPromptSubmit", + serde_json::json!({ + "matcher": "", + "hooks": [{ + "type": "command", + "command": "kimetsu brain context-hook --workspace .", + "statusMessage": "Loading Kimetsu brain context", + "timeout": 30 + }] + }), + ); + if proactive { + upsert_kimetsu_hook( + hooks_obj, + "PreToolUse", + serde_json::json!({ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "kimetsu brain pretool-hook --workspace .", + "statusMessage": "Kimetsu proactive check", + "timeout": 15 + }] + }), + ); + upsert_kimetsu_hook( + hooks_obj, + "PostToolUse", + serde_json::json!({ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "kimetsu brain posttool-hook --workspace .", + "statusMessage": "Kimetsu proactive check", + "timeout": 15 + }] + }), + ); + } + + let text = serde_json::to_string_pretty(&root) + .map_err(|err| format!("serialize Codex hooks: {err}"))?; + write_text_file(&hooks, &text, true)?; + files.push(normalize_path(&hooks)); + Ok(()) +} +``` + +- [ ] **Step 4: Update the caller** + +In `plugin_install`, the Codex arm (line 398) currently calls +`write_codex_hooks(&workspace, force, proactive, &mut files)?;`. Change it to pass the `.codex` dir and drop `force`: + +```rust + write_codex_hooks(&workspace.join(".codex"), proactive, &mut files)?; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test -p kimetsu-chat codex_hooks_merge_preserves_user_hooks` +Expected: PASS. + +Run: `cargo test -p kimetsu-chat plugin_install_writes_optional_and_required_modes plugin_install_no_proactive_skips_tool_hooks` +Expected: PASS (fresh-install shape unchanged: Kimetsu's group is index 0). + +- [ ] **Step 6: Commit** + +```bash +git add crates/kimetsu-chat/src/bridge.rs +git commit -m "fix: merge Codex hooks instead of replacing UserPromptSubmit" +``` + +--- + +## Task 5: MCP config writers become idempotent + +**Files:** +- Modify: `crates/kimetsu-chat/src/bridge.rs` — `write_mcp_config` (lines ~556-595) and `write_codex_config` (lines ~597-641); callers in `plugin_install` (lines 348, 381) +- Test: `crates/kimetsu-chat/src/bridge.rs` (tests module) + +- [ ] **Step 1: Write the failing test** + +Add to `mod tests`: + +```rust +#[test] +fn mcp_config_is_idempotent_and_scopes_keys() { + let root = temp_root("mcp_idempotent"); + let mcp = root.join(".mcp.json"); + + // Workspace style: write both `servers` and `mcpServers`, twice, no error. + write_mcp_config(&mcp, false).unwrap(); + write_mcp_config(&mcp, false).unwrap(); + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); + assert_eq!(value["servers"]["kimetsu"]["command"], "kimetsu"); + assert_eq!(value["mcpServers"]["kimetsu"]["command"], "kimetsu"); + + // Global style: only `mcpServers`, preserving unrelated keys. + let claude_json = root.join(".claude.json"); + fs::write(&claude_json, serde_json::to_string(&json!({ "keepme": 1 })).unwrap()).unwrap(); + write_mcp_config(&claude_json, true).unwrap(); + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&claude_json).unwrap()).unwrap(); + assert_eq!(value["keepme"], 1); + assert_eq!(value["mcpServers"]["kimetsu"]["command"], "kimetsu"); + assert!(value.get("servers").is_none(), "global writes mcpServers only"); + + fs::remove_dir_all(root).ok(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p kimetsu-chat mcp_config_is_idempotent_and_scopes_keys` +Expected: FAIL — `write_mcp_config` currently takes `(path, force)` and errors on the second call (`already has a kimetsu MCP server`). + +- [ ] **Step 3: Rewrite `write_mcp_config`** + +Replace the function (the version reading the file through `write_text_file`, lines ~556-595) with: + +```rust +/// Upsert the `kimetsu` MCP server into a Claude config file. Idempotent — +/// re-running just rewrites the same entry, preserving all other keys. +/// `only_mcp_servers` is true for `~/.claude.json` (global), which uses +/// only the `mcpServers` key; workspace `.mcp.json` also gets `servers`. +fn write_mcp_config(path: &Path, only_mcp_servers: bool) -> Result<(), String> { + let mut root = if path.is_file() { + let text = + fs::read_to_string(path).map_err(|err| format!("read {}: {err}", path.display()))?; + serde_json::from_str::(&text) + .map_err(|err| format!("parse {}: {err}", path.display()))? + } else { + serde_json::json!({}) + }; + let root_obj = root + .as_object_mut() + .ok_or_else(|| format!("{} must be a JSON object", path.display()))?; + let server = serde_json::json!({ + "command": "kimetsu", + "args": ["mcp", "serve", "--workspace", "."] + }); + if !only_mcp_servers { + insert_mcp_server(root_obj, "servers", server.clone(), path)?; + } + insert_mcp_server(root_obj, "mcpServers", server, path)?; + let text = serde_json::to_string_pretty(&root) + .map_err(|err| format!("serialize MCP config: {err}"))?; + write_text_file(path, &text, true) +} +``` + +- [ ] **Step 4: Make `write_codex_config` idempotent** + +In `write_codex_config` (lines ~597-641): change the signature from `(path: &Path, force: bool)` to `(path: &Path)`, and delete the force gate block: + +```rust + if servers.contains_key("kimetsu") && !force { + return Err(format!( + "{} already has a kimetsu MCP server; pass --force", + path.display() + )); + } +``` + +The `servers.insert("kimetsu", …)` below it already upserts, so removal is safe. + +- [ ] **Step 5: Update callers in `plugin_install`** + +- Claude arm (line 348): `write_mcp_config(&mcp, force)?;` → `write_mcp_config(&mcp, false)?;` +- Codex arm (line 381): `write_codex_config(&config, force)?;` → `write_codex_config(&config)?;` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cargo test -p kimetsu-chat mcp_config_is_idempotent_and_scopes_keys` +Expected: PASS. + +Run: `cargo test -p kimetsu-chat` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/kimetsu-chat/src/bridge.rs +git commit -m "fix: make MCP config writers idempotent + scope-aware keys" +``` + +--- + +## Task 6: Auto-refresh Kimetsu-owned generated files + +**Files:** +- Modify: `crates/kimetsu-chat/src/bridge.rs` — the `write_text_file` calls for `bridge.md` (line ~355), `delegate.md` (line ~365), and Codex `SKILL.md` (line ~389) +- Test: `crates/kimetsu-chat/src/bridge.rs` (tests module) + +Generated command/skill docs are Kimetsu-authored, so install should refresh them without `--force`. `CLAUDE.md` stays write-if-missing (unchanged). + +- [ ] **Step 1: Write the failing test** + +Add to `mod tests`: + +```rust +#[test] +fn plugin_install_refreshes_generated_files_without_force() { + let root = temp_root("plugin_install_refresh"); + // First install (Codex) writes SKILL.md. + plugin_install(&root, BridgeTarget::Codex, PluginMode::Optional, false, true).unwrap(); + // Second install with force=false must succeed (refresh, not error). + plugin_install(&root, BridgeTarget::Codex, PluginMode::Required, false, true).unwrap(); + + let skill = fs::read_to_string( + root.join(".codex/skills/kimetsu-bridge/SKILL.md"), + ) + .unwrap(); + // Required-mode content replaced the optional-mode content. + assert!(skill.contains("required") || !skill.is_empty()); + + fs::remove_dir_all(root).ok(); +} +``` + +(Note: this test uses the **current** 5-arg `plugin_install` signature; Task 7 updates it.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p kimetsu-chat plugin_install_refreshes_generated_files_without_force` +Expected: FAIL — second install errors with `…SKILL.md exists; pass --force to replace`. + +- [ ] **Step 3: Force-write the generated docs** + +In `plugin_install`, change the three `write_text_file` calls for generated docs to pass `true` instead of `force`: + +- `bridge.md` call (line ~355-362): last arg `force` → `true`. +- `delegate.md` call (line ~365-372): last arg `force` → `true`. +- Codex `SKILL.md` call (line ~389-396): last arg `force` → `true`. + +Each becomes, e.g.: + +```rust + write_text_file( + &bridge, + match mode { + PluginMode::Optional => CLAUDE_BRIDGE_COMMAND_OPTIONAL, + PluginMode::Required => CLAUDE_BRIDGE_COMMAND_REQUIRED, + }, + true, + )?; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p kimetsu-chat plugin_install_refreshes_generated_files_without_force` +Expected: PASS. + +Run: `cargo test -p kimetsu-chat` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/kimetsu-chat/src/bridge.rs +git commit -m "fix: refresh generated plugin docs on re-install without --force" +``` + +--- + +## Task 7: Thread `scope` through `plugin_install`, CLI, and MCP + +**Files:** +- Modify: `crates/kimetsu-chat/src/bridge.rs` — `PluginInstallReport` (lines 104-109), `plugin_install` (lines 334-411), `write_claude_settings` (lines 660-681), `resolve_home` (new); update existing tests' `plugin_install(...)` calls +- Modify: `crates/kimetsu-cli/src/main.rs` — `PluginInstallArgs` (lines 271-286), `plugin` fn (lines 936-959) +- Modify: `crates/kimetsu-chat/src/mcp_server.rs` — handler (lines 337-361), schema (lines 1761-1776), `PLUGIN_INSTALL_DESCRIPTION` (line 63) +- Test: `crates/kimetsu-chat/src/bridge.rs` (tests module) + +This is the integration task: the signature change and all call sites land in one compiling commit. + +- [ ] **Step 1: Write the failing test (global scope)** + +Add to `mod tests`: + +```rust +#[test] +fn plugin_install_global_writes_to_home_not_workspace() { + let ws = temp_root("plugin_install_global_ws"); + let home = temp_root("plugin_install_global_home"); + + // Claude global install into the injected home. + plugin_install_inner( + &ws, + BridgeTarget::ClaudeCode, + InstallScope::Global, + PluginMode::Optional, + false, + true, + Some(home.as_path()), + ) + .unwrap(); + + assert!(home.join(".claude/settings.json").is_file()); + assert!(home.join(".claude/commands/kimetsu/bridge.md").is_file()); + assert!(home.join(".claude.json").is_file()); + // mcpServers only in the global claude.json. + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(home.join(".claude.json")).unwrap()).unwrap(); + assert_eq!(value["mcpServers"]["kimetsu"]["command"], "kimetsu"); + assert!(value.get("servers").is_none()); + // Nothing written under the workspace. + assert!(!ws.join(".claude").exists()); + assert!(!ws.join(".mcp.json").exists()); + + // Codex global install. + plugin_install_inner( + &ws, + BridgeTarget::Codex, + InstallScope::Global, + PluginMode::Optional, + false, + true, + Some(home.as_path()), + ) + .unwrap(); + assert!(home.join(".codex/config.toml").is_file()); + assert!(home.join(".codex/hooks.json").is_file()); + assert!(!ws.join(".codex").exists()); + + fs::remove_dir_all(ws).ok(); + fs::remove_dir_all(home).ok(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p kimetsu-chat plugin_install_global_writes_to_home_not_workspace` +Expected: FAIL — `plugin_install_inner` and `InstallScope` arg don't exist yet. + +- [ ] **Step 3: Add `scope` to `PluginInstallReport`** + +Change the struct (lines 104-109) to: + +```rust +#[derive(Debug, Clone)] +pub struct PluginInstallReport { + pub target: BridgeTarget, + pub scope: InstallScope, + pub mode: PluginMode, + pub files: Vec, +} +``` + +- [ ] **Step 4: Add `resolve_home`** + +Insert near `normalize_path` (around line 828) in `bridge.rs`: + +```rust +fn resolve_home() -> Result { + std::env::var_os("USERPROFILE") + .filter(|value| !value.is_empty()) + .or_else(|| std::env::var_os("HOME").filter(|value| !value.is_empty())) + .map(PathBuf::from) + .ok_or_else(|| { + "cannot resolve home directory for a global install (set HOME or USERPROFILE)" + .to_string() + }) +} +``` + +- [ ] **Step 5: Rewrite `plugin_install` as a thin wrapper + `plugin_install_inner`** + +Replace the whole `plugin_install` function (lines 334-411) with: + +```rust +pub fn plugin_install( + workspace: &Path, + target: BridgeTarget, + scope: InstallScope, + mode: PluginMode, + force: bool, + proactive: bool, +) -> Result { + let home = match scope { + InstallScope::Global => Some(resolve_home()?), + InstallScope::Workspace => None, + }; + plugin_install_inner( + workspace, + target, + scope, + mode, + force, + proactive, + home.as_deref(), + ) +} + +/// `home` is `Some` for a global install (the directory that stands in for +/// `~`), `None` for a workspace install. Kept separate from `plugin_install` +/// so tests can inject a deterministic home without touching process env. +fn plugin_install_inner( + workspace: &Path, + target: BridgeTarget, + scope: InstallScope, + mode: PluginMode, + force: bool, + proactive: bool, + home: Option<&Path>, +) -> Result { + let workspace = normalize_path(workspace); + let mut files = Vec::new(); + match target { + BridgeTarget::ClaudeCode => { + // MCP: workspace -> ./.mcp.json (servers + mcpServers); + // global -> ~/.claude.json (mcpServers only). + let (mcp, only_mcp_servers) = match home { + Some(home) => (home.join(".claude.json"), true), + None => (workspace.join(".mcp.json"), false), + }; + write_mcp_config(&mcp, only_mcp_servers)?; + files.push(normalize_path(&mcp)); + + let claude_dir = match home { + Some(home) => home.join(".claude"), + None => workspace.join(".claude"), + }; + let commands = claude_dir.join("commands").join("kimetsu"); + fs::create_dir_all(&commands) + .map_err(|err| format!("create {}: {err}", commands.display()))?; + let bridge = commands.join("bridge.md"); + write_text_file( + &bridge, + match mode { + PluginMode::Optional => CLAUDE_BRIDGE_COMMAND_OPTIONAL, + PluginMode::Required => CLAUDE_BRIDGE_COMMAND_REQUIRED, + }, + true, + )?; + files.push(normalize_path(&bridge)); + let delegate = commands.join("delegate.md"); + write_text_file( + &delegate, + match mode { + PluginMode::Optional => CLAUDE_DELEGATE_COMMAND_OPTIONAL, + PluginMode::Required => CLAUDE_DELEGATE_COMMAND_REQUIRED, + }, + true, + )?; + files.push(normalize_path(&delegate)); + write_claude_settings(&claude_dir, force, proactive, &mut files)?; + } + BridgeTarget::Codex => { + let codex_dir = match home { + Some(home) => home.join(".codex"), + None => workspace.join(".codex"), + }; + let config = codex_dir.join("config.toml"); + write_codex_config(&config)?; + files.push(normalize_path(&config)); + + let skill = codex_dir + .join("skills") + .join("kimetsu-bridge") + .join("SKILL.md"); + write_text_file( + &skill, + match mode { + PluginMode::Optional => CODEX_KIMETSU_SKILL_OPTIONAL, + PluginMode::Required => CODEX_KIMETSU_SKILL_REQUIRED, + }, + true, + )?; + files.push(normalize_path(&skill)); + write_codex_hooks(&codex_dir, proactive, &mut files)?; + } + BridgeTarget::Kimetsu => { + // Kimetsu extensions are workspace-only; scope is ignored. + let dir = workspace.join(".kimetsu").join("extensions"); + fs::create_dir_all(&dir).map_err(|err| format!("create {}: {err}", dir.display()))?; + files.push(normalize_path(&dir)); + } + } + Ok(PluginInstallReport { + target, + scope, + mode, + files, + }) +} +``` + +- [ ] **Step 6: Update `write_claude_settings` to take the `.claude` dir** + +Replace `write_claude_settings` (lines 660-681) with: + +```rust +/// Write the Claude Code surface that lives under `.claude/`: the brain +/// `CLAUDE.md` guidance and the `settings.json` hook registration. +fn write_claude_settings( + claude_dir: &Path, + force: bool, + proactive: bool, + files: &mut Vec, +) -> Result<(), String> { + fs::create_dir_all(claude_dir) + .map_err(|err| format!("create {}: {err}", claude_dir.display()))?; + + // CLAUDE.md: seed only when missing, unless forced — never clobber edits. + let claude_md = claude_dir.join("CLAUDE.md"); + if !claude_md.is_file() || force { + write_text_file(&claude_md, CLAUDE_MD_CONTENT, true)?; + } + files.push(normalize_path(&claude_md)); + + let settings = claude_dir.join("settings.json"); + write_claude_hooks(&settings, proactive)?; + files.push(normalize_path(&settings)); + Ok(()) +} +``` + +- [ ] **Step 7: Update existing test call sites in `bridge.rs`** + +Existing tests call the 5-arg `plugin_install`. Add the `InstallScope::Workspace` argument after the target in each: + +- `plugin_install_writes_optional_and_required_modes` (line ~890 and ~931): both `plugin_install(&root, BridgeTarget::Codex, PluginMode::…, …)` calls → insert `InstallScope::Workspace,` after `BridgeTarget::Codex,`. +- `plugin_install_no_proactive_skips_tool_hooks` (line ~953): same insertion. +- `plugin_install_refreshes_generated_files_without_force` (Task 6): both calls → insert `InstallScope::Workspace,` after `BridgeTarget::Codex,`. + +Each becomes, e.g.: + +```rust + let optional = plugin_install( + &root, + BridgeTarget::Codex, + InstallScope::Workspace, + PluginMode::Optional, + false, + true, + ) + .expect("install"); +``` + +- [ ] **Step 8: Run the bridge tests** + +Run: `cargo test -p kimetsu-chat` +Expected: PASS (including the new `plugin_install_global_writes_to_home_not_workspace`). + +- [ ] **Step 9: Wire the CLI `--scope` flag** + +In `crates/kimetsu-cli/src/main.rs`, add to `PluginInstallArgs` (after the `mode` field, lines 276-279): + +```rust + /// Install scope: `workspace` (default) writes .claude/.codex in the + /// workspace; `global` writes to ~/.claude(.json) and ~/.codex for all + /// sessions. + #[arg(long, default_value = "workspace")] + scope: String, +``` + +Then update the `plugin` fn (lines 936-959): + +```rust +fn plugin(command: PluginCommand) -> KimetsuResult<()> { + use kimetsu_chat::{BridgeTarget, InstallScope, PluginMode, plugin_install}; + + match command { + PluginCommand::Install(args) => { + let workspace = args.workspace.canonicalize()?; + let target = BridgeTarget::parse(&args.target) + .map_err(|err| format!("kimetsu plugin install: {err}"))?; + let scope = InstallScope::parse(&args.scope) + .map_err(|err| format!("kimetsu plugin install: {err}"))?; + let mode = PluginMode::parse(&args.mode) + .map_err(|err| format!("kimetsu plugin install: {err}"))?; + let report = + plugin_install(&workspace, target, scope, mode, args.force, !args.no_proactive) + .map_err(|err| format!("kimetsu plugin install: {err}"))?; + println!( + "installed Kimetsu plugin surface for {} ({} scope) in {} mode", + report.target.as_str(), + report.scope.as_str(), + report.mode.as_str() + ); + for file in report.files { + println!(" {}", file.display()); + } + } + } + Ok(()) +} +``` + +- [ ] **Step 10: Wire the MCP `scope` argument** + +In `crates/kimetsu-chat/src/mcp_server.rs`: + +(a) Add `InstallScope` to the import (lines 10-11): + +```rust + BridgeTarget, InstallScope, PluginMode, bridge_export_skill, bridge_import_skill, bridge_scan, + bridge_sync, plugin_install, +``` + +(b) In the `"kimetsu_plugin_install"` handler (lines 337-361), parse `scope` and pass it through, and add it to the output: + +```rust + "kimetsu_plugin_install" => { + let target = BridgeTarget::parse(&string_arg(&arguments, "target")?)?; + let scope = arguments + .get("scope") + .and_then(Value::as_str) + .map(InstallScope::parse) + .transpose()? + .unwrap_or_default(); + let mode = arguments + .get("mode") + .and_then(Value::as_str) + .map(PluginMode::parse) + .transpose()? + .unwrap_or_default(); + let force = arguments + .get("force") + .and_then(Value::as_bool) + .unwrap_or(false); + // v0.8: proactive defaults on; pass proactive:false to skip + // the PreToolUse/PostToolUse Bash hooks. + let proactive = arguments + .get("proactive") + .and_then(Value::as_bool) + .unwrap_or(true); + let report = plugin_install(workspace, target, scope, mode, force, proactive)?; + Ok(json!({ + "target": report.target.as_str(), + "scope": report.scope.as_str(), + "mode": report.mode.as_str(), + "files": report.files, + })) + } +``` + +(c) Add the `scope` property to the input schema (lines 1763-1776), after `"target"`: + +```rust + "target": { "type": "string", "enum": ["claude-code", "codex"] }, + "scope": { + "type": "string", + "enum": ["workspace", "global"], + "description": "workspace (default) installs into this workspace's .claude/.codex; global installs into ~/.claude(.json) and ~/.codex for all sessions." + }, +``` + +(d) Update `PLUGIN_INSTALL_DESCRIPTION` (line 63) to mention scope — append this sentence to the existing string literal, before the closing quote: + +``` + Set scope=workspace (default) to install into this workspace, or scope=global to install into the user's home (~/.claude, ~/.claude.json, ~/.codex) for all sessions. Existing user hooks are preserved (merged, not replaced). +``` + +- [ ] **Step 11: Build the whole workspace** + +Run: `cargo build` +Expected: builds clean (no errors). + +- [ ] **Step 12: Run all tests** + +Run: `cargo test -p kimetsu-chat` +Expected: PASS. + +- [ ] **Step 13: Commit** + +```bash +git add crates/kimetsu-chat/src/bridge.rs crates/kimetsu-cli/src/main.rs crates/kimetsu-chat/src/mcp_server.rs +git commit -m "feat: add --scope workspace|global to kimetsu plugin install" +``` + +--- + +## Task 8: Manual smoke test + docs + +**Files:** +- Verify only (no code); optional README touch if scope deserves a mention. + +- [ ] **Step 1: Workspace install merges, doesn't clobber** + +Run (PowerShell, in a throwaway dir): + +```powershell +$d = Join-Path $env:TEMP ("km-smoke-" + [guid]::NewGuid()) +New-Item -ItemType Directory $d | Out-Null +New-Item -ItemType Directory (Join-Path $d ".claude") | Out-Null +'{ "hooks": { "UserPromptSubmit": [ { "matcher": "", "hooks": [ { "type": "command", "command": "my-hook" } ] } ] } }' | + Out-File -Encoding utf8 (Join-Path $d ".claude/settings.json") +cargo run -p kimetsu-cli -- plugin install claude-code --workspace $d +Get-Content (Join-Path $d ".claude/settings.json") +``` + +Expected: the `UserPromptSubmit` array contains **both** `my-hook` and a `kimetsu brain context-hook` group; `Stop`/`PreToolUse`/`PostToolUse` kimetsu groups are present. + +- [ ] **Step 2: Re-run is idempotent** + +Run: `cargo run -p kimetsu-cli -- plugin install claude-code --workspace $d` again. +Expected: succeeds without `--force`; the kimetsu `UserPromptSubmit` group count stays 1 (array length stays 2). + +- [ ] **Step 3: Global scope smoke (use a sandbox HOME)** + +```powershell +$home2 = Join-Path $env:TEMP ("km-home-" + [guid]::NewGuid()) +New-Item -ItemType Directory $home2 | Out-Null +$env:USERPROFILE = $home2 +cargo run -p kimetsu-cli -- plugin install codex --workspace $d --scope global +Get-ChildItem -Recurse $home2 +``` + +Expected: files appear under `$home2/.codex/` (config.toml, hooks.json, skills/…); nothing new under the workspace `.codex`. + +- [ ] **Step 4: Clean up** + +```powershell +Remove-Item -Recurse -Force $d, $home2 +``` + +(Reopen a fresh shell so the temporary `USERPROFILE` override is dropped.) + +- [ ] **Step 5: Commit any doc updates** + +If you touched `README.md`/`npm/README.md` to document `--scope`, commit: + +```bash +git add README.md npm/README.md +git commit -m "docs: document --scope for kimetsu plugin install" +``` + +--- + +## Done + +All checkboxes complete means: hooks merge non-destructively (including on events Kimetsu shares), install is idempotent, and `--scope workspace|global` works on the CLI and the MCP tool for both Claude Code and Codex. diff --git a/docs/superpowers/specs/2026-06-02-plugin-install-merge-and-scope-design.md b/docs/superpowers/specs/2026-06-02-plugin-install-merge-and-scope-design.md new file mode 100644 index 0000000..285d890 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-plugin-install-merge-and-scope-design.md @@ -0,0 +1,171 @@ +# Plugin install: idempotent hook merge + workspace/global scope + +**Date:** 2026-06-02 +**Status:** Approved (pending spec review) +**Component:** `crates/kimetsu-chat/src/bridge.rs`, `crates/kimetsu-cli/src/main.rs`, `crates/kimetsu-chat/src/mcp_server.rs` + +## Problem + +`kimetsu plugin install` has two shortcomings: + +1. **Hooks are replaced, not merged.** When a workspace (or user) already has + hooks configured, the installer either errors (`already defines hooks; pass + --force`) or, with `--force`, destroys the existing configuration: + - `write_claude_hooks` rebuilds a fresh `hooks` object and does + `root_obj.insert("hooks", …)`, replacing the **entire** hooks tree — every + user hook on every event is lost. + - `write_codex_hooks` overwrites the whole `UserPromptSubmit` array, dropping + any non-Kimetsu entries on that event. + +2. **Install is always workspace-relative.** Every surface is written under the + workspace (`.claude/`, `.codex/`, `.mcp.json`). There is no way to install + Kimetsu globally for all Claude Code / Codex projects. + +## Goals + +- Installing into a workspace/home that already has hooks **adds** Kimetsu's + hooks alongside the existing ones; never removes or overwrites user hooks. +- Re-running install is **idempotent**: no duplicate Kimetsu hook groups, no + `--force` required. +- A `--scope workspace|global` option (default `workspace`) lets the user + install for the current workspace or globally for all Claude Code / Codex + sessions. Applies to both harness targets. + +## Non-goals + +- No interactive TTY prompt; scope is a flag/argument (the npm launcher just + forwards args to the native binary). +- No change to the hook command strings, brain logic, or proactive defaults. +- No new `user` vs `machine` scope distinction beyond workspace/global. + +## Design + +### 1. Idempotent, additive hook merge + +A shared helper merges one Kimetsu hook group into one event array +(both Claude `settings.json` and Codex `hooks.json` use the same +`[{ matcher, hooks: [...] }]` shape): + +``` +merge_kimetsu_hook(event_array, kimetsu_group): + # Kimetsu groups are identified by an inner command containing "kimetsu brain". + if event_array contains a Kimetsu-owned group: + replace that group in place # picks up command / proactive changes + else: + append kimetsu_group # preserve all existing entries +``` + +Applied to every event Kimetsu manages: +- Claude: `UserPromptSubmit`, `Stop`, and (when `proactive`) `PreToolUse`, + `PostToolUse`. +- Codex: `UserPromptSubmit`, and (when `proactive`) `PreToolUse`, `PostToolUse`. + +Consequences: +- Non-Kimetsu hooks are always preserved — including the case where the user + **already has their own hook on an event Kimetsu also uses** (e.g. a custom + `UserPromptSubmit` or `PreToolUse` group). Kimetsu's group is added *into the + same event array, alongside* the existing group(s); the user's groups and + their inner `hooks[]` commands are never read, mutated, or dropped. Both + groups fire (the hooks format runs every matcher group for an event). +- Re-running install never duplicates Kimetsu groups (replace-in-place). +- The `already defines hooks; pass --force` errors are removed. Hook merge no + longer consults `force`. + +Kimetsu always contributes its own matcher group rather than splicing commands +into a user's group, so user groups stay byte-for-byte untouched even when they +share Kimetsu's matcher (`""` for `UserPromptSubmit`/`Stop`, `"Bash"` for the +proactive tool hooks). A Kimetsu-owned group is detected by scanning its +`hooks[].command` strings for the substring `kimetsu brain`; only a group that +matches is ever replaced. + +### 2. MCP config writers become idempotent + +`write_mcp_config` and `write_codex_config` currently error when a `kimetsu` +server is already present unless `--force`. Re-inserting the same `kimetsu` +server key is harmless, so these `--force` gates are removed; the writers always +upsert the `kimetsu` entry and preserve all other servers/keys. + +### 3. Kimetsu-owned generated files auto-refresh + +`bridge.md`, `delegate.md`, and the Codex `SKILL.md` are Kimetsu-authored, not +user content. Install always (re)writes them so the command is fully +re-runnable without `--force`. `CLAUDE.md` keeps its current behavior: written +only when missing, never clobbered. + +`--force` is retained as an accepted flag (back-compat) but its only remaining +effect is overwriting an existing `CLAUDE.md`. + +### 4. Scope resolution + +New enum: + +```rust +pub enum InstallScope { Workspace, Global } +// parse: "workspace" (default, also "ws"/"local"/""), "global" ("g"/"user") +``` + +`plugin_install` gains a `scope: InstallScope` parameter (inserted after +`target`). For `Global`, the home directory is resolved once via the existing +`USERPROFILE`/`HOME` helper and threaded into the path resolver, so tests can +inject a deterministic home (avoids the env-var race noted in the test-isolation +memory). + +Path layout per (target, scope): + +| Surface | Workspace | Global | +|---|---|---| +| Claude MCP | `/.mcp.json` (`servers` + `mcpServers`) | `~/.claude.json` (`mcpServers` only) | +| Claude commands | `/.claude/commands/kimetsu/` | `~/.claude/commands/kimetsu/` | +| Claude CLAUDE.md | `/.claude/CLAUDE.md` | `~/.claude/CLAUDE.md` | +| Claude hooks | `/.claude/settings.json` | `~/.claude/settings.json` | +| Codex config | `/.codex/config.toml` | `~/.codex/config.toml` | +| Codex skill | `/.codex/skills/kimetsu-bridge/` | `~/.codex/skills/kimetsu-bridge/` | +| Codex hooks | `/.codex/hooks.json` | `~/.codex/hooks.json` | + +Notes: +- Hook command strings are unchanged. `--workspace .` / cwd-relative resolution + is correct for a global server too, since `.` resolves to the project the + harness launches in. +- Global Claude MCP merges into `~/.claude.json` `mcpServers` non-destructively + (serde round-trip preserves all keys). The file is pretty-printed on write, so + it is reformatted — accepted trade-off; it is the canonical user-scope MCP + location. +- `BridgeTarget::Kimetsu` is unaffected by scope (no global concept). + +### 5. Surface wiring + +- **CLI** (`PluginInstallArgs`): add `#[arg(long, default_value = "workspace")] + scope: String`; parse to `InstallScope`; pass to `plugin_install`. The install + summary prints the scope alongside target and mode. +- **MCP** (`mcp_server.rs`): add a `scope` property + (`enum ["workspace","global"]`, optional, default `workspace`) to the + `kimetsu_plugin_install` input schema; parse in the handler; pass through. + Update `PLUGIN_INSTALL_DESCRIPTION` to mention scope. +- Update the existing `plugin_install` call sites and unit tests for the new + `scope` parameter. + +## Testing + +Unit tests in `bridge.rs`: + +1. **Preserve user hooks on a shared event (Claude):** seed `settings.json` + with a non-Kimetsu `UserPromptSubmit` group (the same event Kimetsu uses) and + a non-Kimetsu group on an unrelated event (e.g. `SubagentStop`); after + install, assert the user's `UserPromptSubmit` group is still present with its + original command intact **and** Kimetsu's `UserPromptSubmit` group is appended + alongside it (array length 2), and the unrelated event is untouched. +2. **Preserve user hooks on a shared event (Codex):** same for `hooks.json`, + including a user-defined `UserPromptSubmit` group plus Kimetsu's. +3. **Idempotent re-run:** run install twice; assert exactly one Kimetsu group per + event (no duplicates) for both targets. +4. **No --force needed:** install succeeds on a workspace that already has hooks, + without `--force`. +5. **Global scope:** with an injected temp home, install writes under + `home/.claude` + `home/.claude.json` (Claude) and `home/.codex` (Codex), and + does **not** write under the workspace. + +## Risks / trade-offs + +- Reformatting `~/.claude.json` on global Claude MCP install (cosmetic). +- Kimetsu-group detection relies on the `kimetsu brain` command substring; this + is stable across the codebase and is the same marker used elsewhere.