From 9b7035b9c1d62c8a66c56aa683fe45e9d5ed6f9c Mon Sep 17 00:00:00 2001 From: RodCor Date: Mon, 1 Jun 2026 18:20:09 -0300 Subject: [PATCH 01/17] =?UTF-8?q?release:=20v0.8.3=20=E2=80=94=20fix=20Win?= =?UTF-8?q?dows=20npm=20package=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish-npm extracted the Windows archive with unzip, but PowerShell Compress-Archive writes ZIP entries with backslash separators that ubuntu's unzip flattens into a literal filename, so the win32 binary was not found and the job failed after publishing the three Unix platform packages. Extract the Windows zip with 7z (interprets the backslash as a path separator) plus a suffix-match find fallback, and make publishing idempotent so a re-run skips already-published versions. v0.8.2 published @kimetsu-ai/{linux-x64,darwin-x64,darwin-arm64}; the complete set incl. win32 + the main kimetsu-ai package ships in v0.8.3. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 39 ++++++++++++++++++++++++++++------- CHANGELOG.md | 21 ++++++++++++------- Cargo.lock | 12 +++++------ Cargo.toml | 2 +- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dfa1c5..b7fb2d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -333,21 +333,36 @@ jobs: tmp="$(mktemp -d)" if [ "$ext" = "zip" ]; then - unzip -q "$archive" -d "$tmp" + # PowerShell `Compress-Archive` writes ZIP entries with backslash + # separators; Info-ZIP `unzip` flattens those into a literal + # filename so the binary can't be found by path. `7z` (preinstalled + # on ubuntu-latest) normalizes backslashes into real directories. + 7z x -y -o"$tmp" "$archive" >/dev/null else tar -xf "$archive" -C "$tmp" fi pkgdir="stage/${key}" mkdir -p "$pkgdir/bin" + # Prefer the expected layout (`/`), then fall back to + # a recursive search so the step is robust to archive quirks. if [ -f "$tmp/${stem}/${binname}" ]; then - cp "$tmp/${stem}/${binname}" "$pkgdir/bin/${binname}" + src="$tmp/${stem}/${binname}" elif [ -f "$tmp/${binname}" ]; then - cp "$tmp/${binname}" "$pkgdir/bin/${binname}" + src="$tmp/${binname}" else + # Exact match, then suffix match — the latter catches a + # backslash-flattened basename like `\kimetsu.exe` if some + # extractor still mangles the Windows archive. + src="$(find "$tmp" -type f -name "${binname}" | head -n1)" + [ -z "$src" ] && src="$(find "$tmp" -type f -name "*${binname}" | head -n1)" + fi + if [ -z "${src:-}" ] || [ ! -f "$src" ]; then echo "::error::binary ${binname} not found inside ${archive}" + echo "::error::extracted tree:"; find "$tmp" -maxdepth 3 -print exit 1 fi + cp "$src" "$pkgdir/bin/${binname}" [ "$ext" = "zip" ] || chmod +x "$pkgdir/bin/${binname}" node -e ' @@ -365,8 +380,14 @@ jobs: }, null, 2) + "\n"); ' "$pkgdir" "@kimetsu-ai/${key}" "$VERSION" "$osv" "$cpuv" - echo "publishing @kimetsu-ai/${key}@${VERSION}" - ( cd "$pkgdir" && npm publish --access public ) + # Idempotent: skip if this exact version is already on npm, so a + # re-run after a partial failure doesn't choke on EPUBLISHCONFLICT. + if npm view "@kimetsu-ai/${key}@${VERSION}" version >/dev/null 2>&1; then + echo "skip: @kimetsu-ai/${key}@${VERSION} already published" + else + echo "publishing @kimetsu-ai/${key}@${VERSION}" + ( cd "$pkgdir" && npm publish --access public ) + fi done - name: stamp + publish main package @@ -386,8 +407,12 @@ jobs: } fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n"); ' "$VERSION" - echo "publishing kimetsu-ai@${VERSION}" - ( cd npm/kimetsu && npm publish --access public ) + if npm view "kimetsu-ai@${VERSION}" version >/dev/null 2>&1; then + echo "skip: kimetsu-ai@${VERSION} already published" + else + echo "publishing kimetsu-ai@${VERSION}" + ( cd npm/kimetsu && npm publish --access public ) + fi - name: summary run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d16f4..9b0452b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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.2 — npm distribution +## v0.8.3 — npm distribution ADDED * **npm distribution.** Kimetsu now publishes to npm — `npm install -g kimetsu-ai` @@ -22,12 +22,19 @@ ADDED live in [`npm/`](npm/). Installs the `kimetsu` command (also available as `kimetsu-ai`). -## v0.8.1 - -Release-engineering release. The `publish-npm` pipeline first landed here, but -the npm packages themselves ship in v0.8.2 (npm registry naming: the project -lives under the `@kimetsu-ai` scope / `kimetsu-ai` package). crates.io and the -prebuilt binaries released as usual. +FIXED + * **Windows npm package.** The `publish-npm` job now extracts the Windows + archive with `7z` instead of `unzip` (PowerShell `Compress-Archive` uses + backslash path separators that `unzip` flattens), and publishing is + idempotent so a re-run after a partial failure skips already-published + versions. + +## v0.8.1, v0.8.2 + +Release-engineering releases on the road to the npm channel. crates.io and the +prebuilt binaries shipped as usual in both. npm naming settled on the +`@kimetsu-ai` scope / `kimetsu-ai` package (v0.8.1), and the complete, working +npm packages — including Windows — ship in v0.8.3. ## v0.8.0 — proactive recall, selectable embedding model, full MCP control diff --git a/Cargo.lock b/Cargo.lock index 3ad0411..a2b7a27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1483,7 +1483,7 @@ dependencies = [ [[package]] name = "kimetsu-agent" -version = "0.8.2" +version = "0.8.3" dependencies = [ "blake3", "kimetsu-brain", @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "kimetsu-brain" -version = "0.8.2" +version = "0.8.3" dependencies = [ "blake3", "fastembed", @@ -1515,7 +1515,7 @@ dependencies = [ [[package]] name = "kimetsu-chat" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "crossterm", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "kimetsu-cli" -version = "0.8.2" +version = "0.8.3" dependencies = [ "clap", "kimetsu-agent", @@ -1546,7 +1546,7 @@ dependencies = [ [[package]] name = "kimetsu-core" -version = "0.8.2" +version = "0.8.3" dependencies = [ "serde", "serde_json", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "kimetsu-e2e" -version = "0.8.2" +version = "0.8.3" dependencies = [ "kimetsu-agent", "kimetsu-brain", diff --git a/Cargo.toml b/Cargo.toml index 741eb2a..b3f545a 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.2" +version = "0.8.3" edition = "2024" # Rust ecosystem dual-license (matches tokio, serde, fastembed-rs, # etc.). UNLICENSED would block crates.io entirely. From 954b1877dc2268a54ea546e73f85b6658da8acca Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 00:15:30 -0300 Subject: [PATCH 02/17] docs: spec for plugin install hook merge + workspace/global scope Co-Authored-By: Claude Opus 4.8 --- ...2-plugin-install-merge-and-scope-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-plugin-install-merge-and-scope-design.md 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..a82ea3c --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-plugin-install-merge-and-scope-design.md @@ -0,0 +1,158 @@ +# 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 on the same or other events are always preserved. +- 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`. + +A Kimetsu-owned group is detected by scanning its `hooks[].command` strings for +the substring `kimetsu brain`. + +### 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 (Claude):** seed `settings.json` with a non-Kimetsu + `UserPromptSubmit` hook and a non-Kimetsu `PreToolUse` hook; after install + both survive and Kimetsu's groups are present alongside. +2. **Preserve user hooks (Codex):** same for `hooks.json`. +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. From 50a5d005e1d8a9f99f0085e58932fc9f713419e6 Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 00:23:37 -0300 Subject: [PATCH 03/17] docs: clarify hook merge preserves user groups on shared events Co-Authored-By: Claude Opus 4.8 --- ...2-plugin-install-merge-and-scope-design.md | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) 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 index a82ea3c..285d890 100644 --- 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 @@ -61,13 +61,22 @@ Applied to every event Kimetsu manages: - Codex: `UserPromptSubmit`, and (when `proactive`) `PreToolUse`, `PostToolUse`. Consequences: -- Non-Kimetsu hooks on the same or other events are always preserved. +- 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`. -A Kimetsu-owned group is detected by scanning its `hooks[].command` strings for -the substring `kimetsu brain`. +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 @@ -139,10 +148,14 @@ Notes: Unit tests in `bridge.rs`: -1. **Preserve user hooks (Claude):** seed `settings.json` with a non-Kimetsu - `UserPromptSubmit` hook and a non-Kimetsu `PreToolUse` hook; after install - both survive and Kimetsu's groups are present alongside. -2. **Preserve user hooks (Codex):** same for `hooks.json`. +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, From 024704c2e85b2ae8cc2e0d6390ed0fd85e326dba Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 00:32:16 -0300 Subject: [PATCH 04/17] docs: implementation plan for plugin install hook merge + scope Co-Authored-By: Claude Opus 4.8 --- ...26-06-02-plugin-install-merge-and-scope.md | 1220 +++++++++++++++++ 1 file changed, 1220 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-plugin-install-merge-and-scope.md 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. From c091d58788b62e8ec054b5fcec78ab2115d0ebac Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 00:35:56 -0300 Subject: [PATCH 05/17] feat: add InstallScope enum for plugin install scope --- crates/kimetsu-chat/src/bridge.rs | 45 +++++++++++++++++++++++++++++++ crates/kimetsu-chat/src/lib.rs | 4 +-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index c5645bd..3ffffb9 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, @@ -857,6 +890,18 @@ fn slugify(name: &str) -> String { mod tests { use super::*; + #[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() { let root = temp_root("bridge_import_export"); 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; From 38b9b055029e65ae6dd7efbfcb01e035fc6b3b42 Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 00:39:48 -0300 Subject: [PATCH 06/17] feat: add idempotent kimetsu hook-merge helper Co-Authored-By: Claude Sonnet 4.6 --- crates/kimetsu-chat/src/bridge.rs | 74 +++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index 3ffffb9..0ce15f3 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -447,6 +447,45 @@ 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), + } +} + fn write_codex_hooks( workspace: &Path, force: bool, @@ -889,6 +928,41 @@ 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() { From 363dd82958344ab8276073008ace2d964a04ecdf Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 00:45:09 -0300 Subject: [PATCH 07/17] fix: merge Claude hooks instead of replacing user config --- crates/kimetsu-chat/src/bridge.rs | 105 +++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 25 deletions(-) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index 0ce15f3..cb999cb 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -747,15 +747,16 @@ fn write_claude_settings( 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()))?; @@ -767,39 +768,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) @@ -1088,6 +1098,51 @@ 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(); + } + fn temp_root(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) From 596b506614a0e494f9d181a76c935fb640251b1b Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 00:52:57 -0300 Subject: [PATCH 08/17] fix: merge Codex hooks instead of replacing UserPromptSubmit --- crates/kimetsu-chat/src/bridge.rs | 84 ++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index cb999cb..c8bedd7 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -428,7 +428,7 @@ pub fn plugin_install( force, )?; files.push(normalize_path(&skill)); - write_codex_hooks(&workspace, force, proactive, &mut files)?; + write_codex_hooks(&workspace.join(".codex"), proactive, &mut files)?; } BridgeTarget::Kimetsu => { let dir = workspace.join(".kimetsu").join("extensions"); @@ -487,12 +487,11 @@ fn upsert_kimetsu_hook( } 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()))?; @@ -510,15 +509,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", @@ -526,15 +521,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", @@ -542,11 +535,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", @@ -554,9 +548,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)?; @@ -1143,6 +1138,45 @@ mod tests { 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 ." + ); + + fs::remove_dir_all(root).ok(); + } + fn temp_root(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) From 633da4e2f489e9eb869b012cb568a6a90505e4be Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 00:58:47 -0300 Subject: [PATCH 09/17] docs: document write_codex_hooks; assert PostToolUse in test Co-Authored-By: Claude Sonnet 4.6 --- crates/kimetsu-chat/src/bridge.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index c8bedd7..3d1e358 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -486,6 +486,16 @@ fn upsert_kimetsu_hook( } } +/// 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( codex_dir: &Path, proactive: bool, @@ -1173,6 +1183,10 @@ mod tests { 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(); } From 8841ece38f9a367bde119c4bde85673776fa7ac0 Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 01:01:32 -0300 Subject: [PATCH 10/17] fix: make MCP config writers idempotent + scope-aware keys Co-Authored-By: Claude Sonnet 4.6 --- crates/kimetsu-chat/src/bridge.rs | 68 ++++++++++++++++++------------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index 3d1e358..7ec2cdf 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -378,7 +378,7 @@ pub fn plugin_install( // 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)?; + write_mcp_config(&mcp, false)?; files.push(normalize_path(&mcp)); let commands = workspace.join(".claude").join("commands").join("kimetsu"); @@ -411,7 +411,7 @@ pub fn plugin_install( } BridgeTarget::Codex => { let config = workspace.join(".codex").join("config.toml"); - write_codex_config(&config, force)?; + write_codex_config(&config)?; files.push(normalize_path(&config)); let skill = workspace @@ -632,7 +632,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()))?; @@ -644,34 +648,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()))?; @@ -689,12 +679,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( @@ -1191,6 +1175,32 @@ mod tests { 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(); + } + fn temp_root(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) From e32890539e0dd0af65d2b51aed8e8c12212a4efc Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 01:07:20 -0300 Subject: [PATCH 11/17] fix: refresh generated plugin docs on re-install without --force Co-Authored-By: Claude Sonnet 4.6 --- crates/kimetsu-chat/src/bridge.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index 7ec2cdf..78d4d20 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -391,7 +391,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"); @@ -401,7 +401,7 @@ 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 @@ -425,7 +425,7 @@ 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.join(".codex"), proactive, &mut files)?; @@ -1201,6 +1201,23 @@ mod tests { 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, 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(); + assert!(!skill.is_empty()); + + fs::remove_dir_all(root).ok(); + } + fn temp_root(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) From 7f1c768b5e3f827c34112b883a7f01a1a21d96de Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 01:10:56 -0300 Subject: [PATCH 12/17] test: assert generated docs are refreshed; document overwrite --- crates/kimetsu-chat/src/bridge.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index 78d4d20..e5baaca 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -385,6 +385,8 @@ pub fn plugin_install( fs::create_dir_all(&commands) .map_err(|err| format!("create {}: {err}", commands.display()))?; let bridge = commands.join("bridge.md"); + // Generated docs are Kimetsu-owned boilerplate (not user-editable), + // so always overwrite them on install — unlike CLAUDE.md. write_text_file( &bridge, match mode { @@ -1213,7 +1215,11 @@ mod tests { root.join(".codex/skills/kimetsu-bridge/SKILL.md"), ) .unwrap(); - assert!(!skill.is_empty()); + // 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(); } From 79d5f20112860c99e7a7a31cd459284fc71b9360 Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 01:15:53 -0300 Subject: [PATCH 13/17] feat: add --scope workspace|global to kimetsu plugin install Co-Authored-By: Claude Opus 4.8 --- crates/kimetsu-chat/src/bridge.rs | 142 +++++++++++++++++++++----- crates/kimetsu-chat/src/mcp_server.rs | 20 +++- crates/kimetsu-cli/src/main.rs | 17 ++- 3 files changed, 148 insertions(+), 31 deletions(-) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index e5baaca..8080c27 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -137,6 +137,7 @@ pub struct BridgeScan { #[derive(Debug, Clone)] pub struct PluginInstallReport { pub target: BridgeTarget, + pub scope: InstallScope, pub mode: PluginMode, pub files: Vec, } @@ -364,29 +365,75 @@ 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, false)?; + // 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()))?; - let bridge = commands.join("bridge.md"); // 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, match mode { @@ -406,18 +453,18 @@ pub fn plugin_install( 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"); + 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"); @@ -430,9 +477,10 @@ pub fn plugin_install( true, )?; files.push(normalize_path(&skill)); - write_codex_hooks(&workspace.join(".codex"), 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)); @@ -440,6 +488,7 @@ pub fn plugin_install( } Ok(PluginInstallReport { target, + scope, mode, files, }) @@ -721,19 +770,18 @@ 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 only when missing, unless forced — never clobber edits. 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)); @@ -1010,6 +1058,7 @@ mod tests { let optional = plugin_install( &root, BridgeTarget::Codex, + InstallScope::Workspace, PluginMode::Optional, false, true, @@ -1048,7 +1097,7 @@ 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) + 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"); @@ -1073,6 +1122,7 @@ mod tests { plugin_install( &root, BridgeTarget::Codex, + InstallScope::Workspace, PluginMode::Optional, false, false, @@ -1207,9 +1257,9 @@ mod tests { 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(); + 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, PluginMode::Required, false, true).unwrap(); + 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"), @@ -1224,6 +1274,52 @@ mod tests { 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/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/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..2623730 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,24 @@ 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 { From fa2d93145e815c547fd2dbf3f0c2d7b3631aa919 Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 01:22:32 -0300 Subject: [PATCH 14/17] docs: correct CLAUDE.md force-overwrite comment; assert in global test --- crates/kimetsu-chat/src/bridge.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index 8080c27..e960593 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -778,7 +778,9 @@ fn write_claude_settings( 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. + // 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() || force { write_text_file(&claude_md, CLAUDE_MD_CONTENT, true)?; @@ -1292,6 +1294,7 @@ mod tests { .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 = From 740ddb2bfe68819f92266596ff422ae8a4493bc4 Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 01:29:25 -0300 Subject: [PATCH 15/17] docs: document --scope and hook-merge behavior for plugin install Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0452b..3057857 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). +## Unreleased + +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/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. From 247362ac5ed6f3de07a9bcea521ffadff78686d9 Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 09:34:20 -0300 Subject: [PATCH 16/17] =?UTF-8?q?release:=20v0.8.4=20=E2=80=94=20non-destr?= =?UTF-8?q?uctive=20plugin=20install=20+=20global=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 is idempotent (no duplicate groups) and the MCP config and generated docs refresh without needing --force. Adds --scope workspace|global (default workspace). Global installs the surface into the user's home for every session: ~/.claude + ~/.claude.json (mcpServers) for Claude Code, ~/.codex for Codex. Also exposed as the `scope` argument on the kimetsu_plugin_install MCP tool. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3057857..e260b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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). -## Unreleased +## v0.8.4 — non-destructive plugin install + global scope ADDED * **Global plugin install.** `kimetsu plugin install --scope global` 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. From 418591053de8a304756541becddcd708cb73e6ee Mon Sep 17 00:00:00 2001 From: RodCor Date: Tue, 2 Jun 2026 12:09:45 -0300 Subject: [PATCH 17/17] style: cargo fmt (rustfmt line wrapping) Co-Authored-By: Claude Opus 4.8 --- crates/kimetsu-chat/src/bridge.rs | 82 +++++++++++++++++++++++++------ crates/kimetsu-cli/src/main.rs | 12 +++-- 2 files changed, 75 insertions(+), 19 deletions(-) diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index e960593..c18b74e 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -531,7 +531,10 @@ fn upsert_kimetsu_hook( *entry = serde_json::Value::Array(vec![group]); return; }; - match list.iter_mut().find(|existing| is_kimetsu_hook_group(existing)) { + match list + .iter_mut() + .find(|existing| is_kimetsu_hook_group(existing)) + { Some(slot) => *slot = group, None => list.push(group), } @@ -1006,7 +1009,11 @@ mod tests { // 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.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. @@ -1018,8 +1025,14 @@ mod tests { #[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("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"); @@ -1099,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, InstallScope::Workspace, 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")); @@ -1171,7 +1191,11 @@ mod tests { 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.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. @@ -1180,8 +1204,14 @@ mod tests { "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"); + 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(); } @@ -1244,13 +1274,20 @@ mod tests { // 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(); + 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"); + assert!( + value.get("servers").is_none(), + "global writes mcpServers only" + ); fs::remove_dir_all(root).ok(); } @@ -1259,14 +1296,27 @@ mod tests { 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(); + 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"), + 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"), diff --git a/crates/kimetsu-cli/src/main.rs b/crates/kimetsu-cli/src/main.rs index 2623730..17460d6 100644 --- a/crates/kimetsu-cli/src/main.rs +++ b/crates/kimetsu-cli/src/main.rs @@ -950,9 +950,15 @@ fn plugin(command: PluginCommand) -> KimetsuResult<()> { .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}"))?; + 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(),