From 97881a7cf3ef49588231901bd23e95ae2e8ad49d Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Fri, 8 May 2026 22:34:22 +0100 Subject: [PATCH 1/9] feat(plugin): add Claude Code plugin v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship cix as an installable Claude Code plugin alongside the existing manual skill, so users get one-command setup with bundled CLI, slash commands, and behavioral hooks. Plugin layout (plugins/cix/): - skills/cix/SKILL.md — copy of the legacy skill with tightened description + when_to_use trigger phrases for reliable auto-trigger - commands/{search,def,refs,init,status,summary}.md — 6 slash commands exposed as /cix:* with frontmatter + bash execution - hooks/hooks.json — SessionStart + PreToolUse(Grep|Glob) registration - scripts/cix-wrapper.sh — "use system cix or auto-install via the official install.sh" wrapper, exposed on PATH via bin/cix symlink - scripts/session-start.sh — one-time reminder injected via hookSpecificOutput.additionalContext when project has .cixignore - scripts/grep-nudge.sh — exponential-backoff nudge on Grep/Glob calls (fires on call #1, 2, 4, 8, 16, …; ~7 nudges per 100-Grep session) - .claude-plugin/plugin.json — manifest, version 0.1.0 Marketplace (.claude-plugin/marketplace.json): - Repo doubles as marketplace; one plugin entry pointing to plugins/cix/ - Compatible with `--sparse .claude-plugin plugins` so users skip the ~80 MB server/CLI/dashboard checkout Docs: - CLAUDE-CODE-PLUGIN.md (new) — full user-facing guide: prerequisites (server runs separately, CLI must be configured independently), install paths, verification, scope choice, uninstall, troubleshooting - README.md — Claude Code section now exposes two integration paths: Option A "Plugin (recommended)" linking to CLAUDE-CODE-PLUGIN.md, and Option B "Skill (manual, legacy)" preserved verbatim for users who can't use the plugin system Adoption design (4 layers, total context overhead ~8 KB per cix-heavy session, ~400 B if cix isn't used): 1. Skill description (native, always-in-context) 2. SessionStart hook reminder (once per session) 3. PreToolUse(Grep|Glob) nudges (exponential backoff) 4. SKILL.md body lazy-loaded once via native skill mechanism Validated with `claude plugin validate` for both plugin and marketplace manifests; unit-tested hook scripts (backoff schedule, .cixignore detection); installed locally via `claude plugin install` and verified the wrapper resolves to system cix or bootstraps install.sh on demand. Co-Authored-By: Claude Opus 4.7 --- .claude-plugin/marketplace.json | 25 ++ CLAUDE-CODE-PLUGIN.md | 386 +++++++++++++++++++++++++ README.md | 25 +- plugins/cix/.claude-plugin/plugin.json | 14 + plugins/cix/README.md | 137 +++++++++ plugins/cix/bin/cix | 1 + plugins/cix/commands/def.md | 15 + plugins/cix/commands/init.md | 17 ++ plugins/cix/commands/refs.md | 14 + plugins/cix/commands/search.md | 18 ++ plugins/cix/commands/status.md | 15 + plugins/cix/commands/summary.md | 16 + plugins/cix/hooks/hooks.json | 25 ++ plugins/cix/scripts/cix-wrapper.sh | 91 ++++++ plugins/cix/scripts/grep-nudge.sh | 80 +++++ plugins/cix/scripts/session-start.sh | 67 +++++ plugins/cix/skills/cix/SKILL.md | 217 ++++++++++++++ 17 files changed, 1162 insertions(+), 1 deletion(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 CLAUDE-CODE-PLUGIN.md create mode 100644 plugins/cix/.claude-plugin/plugin.json create mode 100644 plugins/cix/README.md create mode 120000 plugins/cix/bin/cix create mode 100644 plugins/cix/commands/def.md create mode 100644 plugins/cix/commands/init.md create mode 100644 plugins/cix/commands/refs.md create mode 100644 plugins/cix/commands/search.md create mode 100644 plugins/cix/commands/status.md create mode 100644 plugins/cix/commands/summary.md create mode 100644 plugins/cix/hooks/hooks.json create mode 100755 plugins/cix/scripts/cix-wrapper.sh create mode 100755 plugins/cix/scripts/grep-nudge.sh create mode 100755 plugins/cix/scripts/session-start.sh create mode 100644 plugins/cix/skills/cix/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..e2e09a3 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-marketplace.json", + "name": "code-index", + "owner": { + "name": "dvcdsys", + "email": "dvcdsys@gmail.com" + }, + "description": "Marketplace for cix — semantic code search and navigation tooling for Claude Code", + "plugins": [ + { + "name": "cix", + "source": "./plugins/cix", + "description": "Semantic code search and navigation. Bundles the cix CLI and nudges Claude to prefer cix over Grep for semantic queries.", + "author": { + "name": "dvcdsys" + }, + "homepage": "https://github.com/dvcdsys/code-index", + "repository": "https://github.com/dvcdsys/code-index", + "license": "MIT", + "keywords": ["search", "code-search", "semantic", "navigation", "indexing", "embeddings"], + "category": "developer-tools", + "tags": ["search", "indexing", "ai", "embeddings"] + } + ] +} diff --git a/CLAUDE-CODE-PLUGIN.md b/CLAUDE-CODE-PLUGIN.md new file mode 100644 index 0000000..0875829 --- /dev/null +++ b/CLAUDE-CODE-PLUGIN.md @@ -0,0 +1,386 @@ +# Claude Code Plugin (`cix`) + +Official Claude Code plugin for `cix` — semantic code search and navigation. +Installs in two commands, bundles the `cix` CLI, ships slash commands, a +skill, and behavioral hooks that nudge Claude to prefer `cix` over `Grep` +for semantic queries. + +> [!IMPORTANT] +> **The plugin does NOT include the `cix` server.** The plugin only ships +> the CLI client and Claude Code integration glue. You must run a `cix` +> server separately and point the CLI at it (see [Prerequisites](#prerequisites) +> below). Without a reachable server the plugin can install, but `cix` +> commands will fail at runtime. + +--- + +## What you get + +When the plugin is enabled, every Claude Code session in a `cix`-indexed +project automatically gets: + +- **Slash commands** — `/cix:search`, `/cix:def`, `/cix:refs`, `/cix:init`, + `/cix:status`, `/cix:summary`. Invocable manually or by Claude. +- **Bundled `cix` CLI** — the plugin auto-installs `cix` to + `~/.local/bin/` on first use if it isn't already in your `PATH` (no + sudo). If you already installed `cix` system-wide via `install.sh`, + the plugin reuses that binary — no second copy. +- **`cix` skill (`SKILL.md`)** — full usage reference (when to reach for + cix vs Grep, query patterns, scoring landscape, CLI flags). **Lazy-loaded** + by Claude Code — enters context only when invoked, stays once per + session. +- **Behavioral hooks:** + - **`SessionStart`** — at session start, if the project is cix-indexed + (has `.cixignore`), injects a one-line reminder telling Claude that + semantic search is available. + - **`PreToolUse(Grep|Glob)`** — when Claude is about to use Grep/Glob + in a cix-indexed project, occasionally suggests `cix search` instead. + Throttled with **exponential backoff** (fires on call #1, 2, 4, 8, + 16, 32, 64, …) so the reminder doesn't become noise — at most ~7 + nudges per 100-Grep session. + +In projects **without** a cix index, all hooks are silent — the plugin +adds zero context overhead. + +--- + +## How it works + +The plugin uses a **4-layer adoption design** so SKILL.md loads at most +once and nudges don't spam the context. + +| Layer | Mechanism | Cost over a 100-prompt session | +|---|---|---| +| 1. Skill description | Native Claude Code (always-in-context, ~200 B) | ~200 B once | +| 2. SessionStart hook | One-time reminder in indexed projects | ~200 B once | +| 3. PreToolUse(Grep\|Glob) hook | Exponential-backoff nudge | ~80 B × ~7 calls = ~560 B | +| 4. SKILL.md body | Native lazy-load (skill mechanism) | ~7 KB **once** if invoked | + +**Total plugin context overhead** in a session that uses cix heavily: +~8 KB. In a session that doesn't touch cix at all: ~400 B (skill +description + slash command metadata). + +The SKILL.md body is **never duplicated** — Claude Code's skill mechanism +guarantees a single insertion that stays in context for the session +([skill content lifecycle docs](https://code.claude.com/docs/en/skills#skill-content-lifecycle)). + +--- + +## Prerequisites + +The plugin is the **client side** of the cix stack. Before installing, +make sure you have: + +### 1. A reachable `cix` server + +The CLI talks to a `cix-server` over HTTP. The server runs separately +(in Docker, natively on macOS, or remotely). See the [main README](README.md) +for the three deployment modes: + +- Docker CPU — `docker compose up -d` +- Docker CUDA (NVIDIA GPU) — `docker compose -f docker-compose.cuda.yml up -d` +- Native macOS (Apple Silicon Metal) — `cd server && make bundle && make run` + +Verify it's up: + +```bash +curl http://localhost:21847/health # → {"status":"ok"} +``` + +If you have a fresh database, the server requires `CIX_BOOTSTRAP_ADMIN_EMAIL` +and `CIX_BOOTSTRAP_ADMIN_PASSWORD` on first boot — see the main README's +Quick Start. + +### 2. The `cix` CLI configured to point at that server + +The CLI configuration is **independent of the plugin**. The plugin uses +whatever config the CLI reads from `~/.cix/config.yaml`. Configure it +once: + +```bash +cix config set api.url http://localhost:21847 +cix config set api.key cix_ +``` + +Get an API key from one of: + +- The dashboard: open `http://localhost:21847/dashboard` → **API Keys** → + mint a new key. +- Your `.env` file: `grep CIX_API_KEY /path/to/code-index/.env | cut -d= -f2`. + +Verify the CLI can reach the server: + +```bash +cix list # should show projects (or "no projects yet") +``` + +> [!IMPORTANT] +> If the CLI is not configured (or the server is unreachable), Claude +> will see error output from `cix` commands. The plugin can't paper +> over a missing server — it's a thin wrapper, not a replacement. + +### 3. Claude Code v2.1.0 or later + +The plugin uses `hookSpecificOutput.additionalContext` for hook-driven +nudges, which requires Claude Code 2.1.0+. Check with `claude --version`. + +--- + +## Installation + +There are three install paths depending on your scenario. + +### Option A — From GitHub (recommended for end users) + +Once the plugin is merged to `main`: + +```bash +claude plugin marketplace add dvcdsys/code-index --sparse .claude-plugin plugins +claude plugin install cix@code-index --scope user +``` + +The `--sparse` flag limits checkout to the plugin directories +(`.claude-plugin/` + `plugins/`), avoiding a full clone of the server, +CLI source, and dashboard build. + +### Option B — From a specific branch or tag (testing / pinned versions) + +```bash +# From a branch (e.g. testing a feature branch) +claude plugin marketplace add dvcdsys/code-index@feat/claude-code-plugin \ + --sparse .claude-plugin plugins + +# From a tag (pinned release) +claude plugin marketplace add dvcdsys/code-index@plugin/v0.1.0 \ + --sparse .claude-plugin plugins + +claude plugin install cix@code-index --scope user +``` + +### Option C — From a local clone (plugin development) + +```bash +git clone https://github.com/dvcdsys/code-index +claude plugin marketplace add /absolute/path/to/code-index +claude plugin install cix@code-index --scope user +``` + +### Choosing the scope + +`--scope user` (default in our examples) — plugin available in every +project. Stored in `~/.claude/settings.json`. **Recommended for personal +use.** + +`--scope project` — committed to `.claude/settings.json`, shared with +teammates via git. Good for team-wide enable. + +`--scope local` — stored in `.claude/settings.local.json`, gitignored. +Only the current project. Useful when testing. + +After install, restart Claude Code (or run `/reload-plugins` in an +existing session) so hooks register. + +--- + +## Verification + +```bash +# Plugin is installed and enabled +claude plugin list +# Expected output (excerpt): +# ❯ cix@code-index +# Version: 0.1.0 +# Scope: user +# Status: ✔ enabled + +# Both manifests pass official validation +claude plugin validate $(claude plugin list --json | jq -r '.[] | select(.id=="cix@code-index").installPath') + +# The bundled CLI wrapper works +$(claude plugin list --json | jq -r '.[] | select(.id=="cix@code-index").installPath')/bin/cix --version +# → cix v0.X.Y +``` + +Then in a Claude Code session inside a cix-indexed project: + +1. Type `/cix` and check autocomplete shows 6 commands (`search`, `def`, + `refs`, `init`, `status`, `summary`). +2. Run `/cix:status` — should print `cix status` output. +3. Ask Claude something semantic ("find the auth middleware") and watch + whether it reaches for `cix search` or falls back to Grep. + +--- + +## Uninstall + +### Plugin only (keep marketplace registered) + +```bash +claude plugin uninstall cix@code-index --scope user +``` + +### Plugin + marketplace + cache (full cleanup) + +```bash +claude plugin uninstall cix@code-index --scope user +claude plugin marketplace remove code-index +rm -rf ~/.claude/plugins/cache/code-index # belt-and-suspenders +``` + +This does **not** uninstall the `cix` CLI itself or stop the cix server +— those are independent. Remove them separately if needed: + +```bash +# Remove the CLI binary if you used the plugin's bootstrap install +rm ~/.local/bin/cix + +# Stop the server +docker compose down # CPU mode +docker compose -f docker-compose.cuda.yml down # CUDA mode +launchctl unload ~/Library/LaunchAgents/com.cix.server.plist # native macOS +``` + +### Troubleshooting "wrong scope" errors + +If `claude plugin uninstall` complains the plugin is in a different +scope, check the master state file: + +```bash +jq '.plugins["cix@code-index"]' ~/.claude/plugins/installed_plugins.json +``` + +This shows every install (with `scope` and, for local installs, +`projectPath`). For local-scope uninstall, `cd` to the registered +`projectPath` first, then re-run the uninstall. + +--- + +## Configuration + +Most plugin behavior is automatic. The few env vars you can set: + +| Variable | Default | Effect | +|---|---|---| +| `CIX_PLUGIN_BIN_DIR` | `$HOME/.local/bin` | Where the wrapper installs `cix` if it isn't on PATH yet. | + +The CLI config (`~/.cix/config.yaml`) is **separate** from the plugin — +the plugin doesn't write to it. Configure the CLI once (see +[Prerequisites](#prerequisites)) and the plugin will use that config. + +### Per-project trigger threshold + +The plugin only nudges Claude in projects with a `.cixignore` file at +the project root. If your project is indexed but doesn't have one, +create an empty `.cixignore`: + +```bash +touch .cixignore +``` + +This is also the recommended way to give cix per-project ignore patterns +(it follows `.gitignore` syntax). + +--- + +## What the plugin does NOT do + +To keep the v0.1 surface focused, the plugin intentionally excludes: + +- **MCP server** — cix isn't exposed as an MCP tool yet (planned for v0.2). + This means the plugin works in Claude Code (CLI + Code mode in Claude + Desktop) but **not** in pure Claude Desktop chat sessions, which only + consume MCP servers. +- **Server lifecycle management** — the plugin will not start, stop, or + configure your `cix-server`. That's intentional: the server is shared + infrastructure (one server can index many projects, used by many + agents and IDEs), not a per-plugin concern. +- **CLI configuration UI** — `cix config set` is the source of truth. + The plugin reads it but doesn't replace it. + +--- + +## Troubleshooting + +### "cix: command not found" inside Claude Code + +The plugin's `bin/` should be on `PATH` while the plugin is enabled. +Check: + +```bash +claude plugin list # plugin enabled? +ls -la ~/.claude/plugins/cache/code-index/cix/*/bin/cix # symlink exists? +``` + +If the symlink is missing, reinstall: +`claude plugin uninstall cix@code-index --scope user && claude plugin install cix@code-index --scope user`. + +### Hooks silent in indexed project + +The hooks gate on `.cixignore` at the project root. Verify: + +```bash +ls -la $(pwd)/.cixignore +``` + +If it's missing but the project IS indexed, create an empty one +(`touch .cixignore`) and restart your Claude Code session. + +### Hooks too loud / too quiet + +Edit `scripts/grep-nudge.sh` in your local clone of the plugin, change +the power-of-2 check to your taste, and reinstall. Default schedule +(1, 2, 4, 8, 16, …) was chosen to balance "loud at start" with +"fade away". + +### `cix` commands return errors at runtime + +The CLI runs but the server is unreachable, or the API key is invalid. +Verify each step from [Prerequisites](#prerequisites): + +```bash +curl http://localhost:21847/health +cix config show +cix list +``` + +### Two duplicate entries in `claude plugin list` + +This usually means you installed the plugin in two scopes (e.g. `local` +plus `user`). Check the master state: + +```bash +jq '.plugins["cix@code-index"]' ~/.claude/plugins/installed_plugins.json +``` + +Uninstall from the unwanted scope (you may need to `cd` to the +registered `projectPath` for local-scope uninstall). + +### Plugin installs but slash commands don't appear + +Slash commands are loaded at session start. After install, run +`/reload-plugins` in an existing Claude Code session, or quit and +re-open Claude Code. + +--- + +## Roadmap + +**v0.2** (after v0.1 feedback): + +- **MCP server** exposing `cix_search`, `cix_definitions`, `cix_references` + as native Claude tools, so cix becomes available in Claude Desktop chat + and any other MCP-compatible client. +- **`PreToolUse(Bash)` hook** that catches inline `grep` calls (not just + the `Grep` tool) and routes them through cix where appropriate. +- **`cix-explorer` subagent** preconfigured for codebase exploration tasks + (`Skill: cix` + read-only tool whitelist + `context: fork`). + +**v0.3+:** auto-`cix init` on first use, hot-reload of the skill from +git after a `cix reindex`, distribution as an officially-listed plugin +in `claude-plugins-official`. + +--- + +## License + +MIT — same as the parent project. diff --git a/README.md b/README.md index 36640a2..02c6754 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,30 @@ Config file: `~/.cix/config.yaml` ### Claude Code -Install the bundled skill so Claude knows to use `cix` automatically: +Two integration paths — pick whichever fits your setup. + +#### Option A — Plugin (recommended) + +The official `cix` Claude Code plugin bundles the skill, slash commands +(`/cix:search`, `/cix:def`, `/cix:refs`, `/cix:init`, `/cix:status`, +`/cix:summary`), the CLI auto-bootstrap, and behavioral hooks that nudge +Claude to prefer `cix` over Grep for semantic queries. + +```bash +claude plugin marketplace add dvcdsys/code-index --sparse .claude-plugin plugins +claude plugin install cix@code-index --scope user +``` + +> The server still runs separately, and the CLI must be configured to +> point at it (steps above in [Quick Start](#quick-start)). +> +> See **[CLAUDE-CODE-PLUGIN.md](CLAUDE-CODE-PLUGIN.md)** for full details: +> prerequisites, install paths (branch / tag / local clone), +> verification, configuration, uninstall, and troubleshooting. + +#### Option B — Skill (manual, legacy) + +For environments without plugin support, copy the bundled skill manually: ```bash cp -r skills/cix ~/.claude/skills/cix diff --git a/plugins/cix/.claude-plugin/plugin.json b/plugins/cix/.claude-plugin/plugin.json new file mode 100644 index 0000000..a193e5a --- /dev/null +++ b/plugins/cix/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "cix", + "version": "0.1.0", + "description": "Semantic code search and navigation for Claude Code via the cix index. Bundles the cix CLI (auto-installs if missing) and nudges Claude to prefer cix over Grep for semantic queries.", + "author": { + "name": "dvcdsys", + "email": "dvcdsys@gmail.com" + }, + "homepage": "https://github.com/dvcdsys/code-index", + "repository": "https://github.com/dvcdsys/code-index", + "license": "MIT", + "keywords": ["search", "code-search", "semantic", "navigation", "indexing", "embeddings", "ai"] +} diff --git a/plugins/cix/README.md b/plugins/cix/README.md new file mode 100644 index 0000000..3c51c5f --- /dev/null +++ b/plugins/cix/README.md @@ -0,0 +1,137 @@ +# cix — Claude Code plugin + +Semantic code search and navigation for Claude Code, powered by the +[cix](https://github.com/dvcdsys/code-index) index. + +## What you get + +- **`/cix:search`, `/cix:def`, `/cix:refs`, `/cix:init`, `/cix:status`, + `/cix:summary`** — slash commands wrapping the most-used `cix` CLI + operations. +- **Bundled cix CLI** — the plugin auto-installs `cix` on first use if + it isn't already in your `PATH` (no sudo, installs to `~/.local/bin`). + If you already have `cix` installed via the official `install.sh`, the + plugin just uses it. +- **`cix` skill (SKILL.md)** — lazy-loaded full instruction sheet + covering when to use cix vs Grep, query patterns, scoring landscape, + and CLI flags. Loads into the conversation only when Claude or you + invoke it (`/cix:search`, `/cix-skill`, or auto-trigger on a relevant + prompt). Stays in context for the rest of the session — never + duplicated. +- **Behavioral nudges (hooks):** + - **SessionStart** — at session start, if the current project has a + `.cix/` index, injects a one-line reminder telling Claude that + semantic search is available. + - **PreToolUse(Grep|Glob)** — when Claude is about to use Grep/Glob + in an indexed project, occasionally suggests `cix search` instead. + Throttled with **exponential backoff** (fires on call #1, 2, 4, 8, + 16, 32, 64, …) so the reminder doesn't become noise — at most ~7 + nudges per 100-Grep session. + +## Install + +From an existing Claude Code marketplace: + +``` +/plugin marketplace add dvcdsys/code-index +/plugin install cix@code-index +/reload-plugins # or restart Claude Code +``` + +Or for local development against this repo: + +``` +/plugin marketplace add /path/to/code-index +/plugin install cix@code-index --scope local +``` + +## Requirements + +- **Claude Code v2.1.0+** (uses `hookSpecificOutput.additionalContext` + for hook-driven nudges). +- **`curl`** — only needed the first time, for the auto-bootstrap of + the `cix` CLI. +- **A reachable `cix-server`** — the CLI is a thin client. If you don't + yet have a server, see the project README for Docker setup + instructions. + +## How adoption works (the design) + +The plugin uses a 4-layer approach so SKILL.md loads at most once and +nudges don't spam the context: + +| Layer | Mechanism | Cost over a 100-prompt session | +|---|---|---| +| 1. Skill description | Native Claude Code (always-in-context, ~200 B) | ~200 B once | +| 2. SessionStart hook | One-time reminder in indexed projects | ~200 B once | +| 3. PreToolUse(Grep\|Glob) hook | Exponential-backoff nudge | ~80 B × ~7 calls = ~560 B | +| 4. SKILL.md body | Native lazy-load (skill mechanism) | ~7 KB **once** if invoked | + +Total plugin context overhead in a session that uses cix heavily: +~8 KB. In a session that doesn't touch cix at all: ~400 B (skill +description + slash command metadata). + +The SKILL.md body is **never duplicated** — Claude Code's skill +mechanism guarantees a single insertion that stays in context for the +session. See the [skill content lifecycle](https://code.claude.com/docs/en/skills#skill-content-lifecycle) +docs. + +## Configuration + +### Where the bundled CLI is installed + +The wrapper installs `cix` to `~/.local/bin/cix` by default. To override +the install location, set `CIX_PLUGIN_BIN_DIR` in your environment: + +```bash +export CIX_PLUGIN_BIN_DIR=/usr/local/bin # if you want sudo-installed +``` + +If you've already installed `cix` system-wide (e.g. via the project's +`install.sh`), the wrapper detects it and uses that binary — no second +copy is downloaded. + +### Skipping the auto-install + +Set `CIX_PLUGIN_BIN_DIR` to a directory that already contains a working +`cix` binary, or simply make sure `cix` is in your `$PATH` before +enabling the plugin. + +### Hook state cleanup + +The PreToolUse hook keeps a per-session counter at +`/tmp/cix-grep-count-$SESSION_ID`. These are zero-byte markers that +`/tmp` cleans up on reboot. No manual cleanup needed. + +## Files + +| Path | Purpose | +|---|---| +| `.claude-plugin/plugin.json` | Plugin manifest | +| `skills/cix/SKILL.md` | Lazy-loaded usage skill (~7 KB) | +| `commands/*.md` | Six slash commands | +| `hooks/hooks.json` | SessionStart + PreToolUse(Grep\|Glob) registration | +| `scripts/cix-wrapper.sh` | "Use system or auto-install" CLI wrapper | +| `scripts/session-start.sh` | One-time session reminder | +| `scripts/grep-nudge.sh` | Exponential-backoff Grep nudge | +| `bin/cix` | Symlink to wrapper, exposed on `$PATH` while plugin enabled | + +## Troubleshooting + +- **"cix: command not found" inside Claude Code Bash tool** — the + plugin isn't enabled or `bin/cix` isn't on `$PATH`. Run + `/plugin list` and `which cix` from inside a Claude Code session. +- **Hooks not firing** — run Claude Code with `--debug` and look for + hook registration messages. Check `/Users/dvcdsys/.claude/...` (or + your local cache path) for the hook scripts and verify they're + executable: `ls -la $(claude plugin list ... | path)/scripts/`. +- **Nudges feel too frequent / too rare** — edit the power-of-2 check + in `scripts/grep-nudge.sh` to your taste. The current schedule + (1, 2, 4, 8, 16, …) was chosen to balance "loud at start" with + "fade away". +- **"This project has a cix semantic code index" never appears** — + the project must contain a `.cix/` directory. Run `/cix:init` first. + +## License + +MIT — same as the parent project. diff --git a/plugins/cix/bin/cix b/plugins/cix/bin/cix new file mode 120000 index 0000000..4263b5f --- /dev/null +++ b/plugins/cix/bin/cix @@ -0,0 +1 @@ +../scripts/cix-wrapper.sh \ No newline at end of file diff --git a/plugins/cix/commands/def.md b/plugins/cix/commands/def.md new file mode 100644 index 0000000..c53303e --- /dev/null +++ b/plugins/cix/commands/def.md @@ -0,0 +1,15 @@ +--- +description: Find symbol definition(s) via cix — go-to-definition across the indexed codebase +argument-hint: [--kind function|class|method|type] [--file ] +allowed-tools: Bash(cix *) +--- + +Look up the definition of the symbol **$ARGUMENTS** in the cix index: + +```! +cix definitions $ARGUMENTS +``` + +If multiple matches are returned, point out the most likely one based on +context. If nothing is found, suggest `cix symbols $ARGUMENTS` for a +broader name search. diff --git a/plugins/cix/commands/init.md b/plugins/cix/commands/init.md new file mode 100644 index 0000000..7fc49aa --- /dev/null +++ b/plugins/cix/commands/init.md @@ -0,0 +1,17 @@ +--- +description: Initialize the cix index for the current project (registers, indexes, starts file watcher) +allowed-tools: Bash(cix *) +--- + +Initialize the cix index for the current project. This registers the +project with the cix server, performs a full initial index, and starts +the file-watcher daemon for auto-reindex on changes. + +```! +cix init +``` + +If the indexing run is in-progress, you can monitor it with `/cix:status`. +If it fails, common causes are: cix-server not reachable, missing +`CIX_API_KEY` env var, or `~/.cix/data` permission issues. Check +`cix status` for details. diff --git a/plugins/cix/commands/refs.md b/plugins/cix/commands/refs.md new file mode 100644 index 0000000..a5e3adb --- /dev/null +++ b/plugins/cix/commands/refs.md @@ -0,0 +1,14 @@ +--- +description: Find symbol references via cix — locate every usage of a symbol across the codebase +argument-hint: [--file ] [--limit ] +allowed-tools: Bash(cix *) +--- + +Find references to the symbol **$ARGUMENTS** in the cix index: + +```! +cix references $ARGUMENTS +``` + +Group the references by file and call out any high-traffic call sites or +suspicious usage patterns. If you need fewer results, add `--limit 20`. diff --git a/plugins/cix/commands/search.md b/plugins/cix/commands/search.md new file mode 100644 index 0000000..7e3c1c7 --- /dev/null +++ b/plugins/cix/commands/search.md @@ -0,0 +1,18 @@ +--- +description: Semantic code search via cix — find code by meaning, not by exact strings +argument-hint: +allowed-tools: Bash(cix *) +--- + +Run a semantic search through the cix index for the query: **$ARGUMENTS** + +```! +cix search "$ARGUMENTS" +``` + +Summarize the most relevant matches above. If results look weak, try: +- A more specific phrasing that names the area or symbol +- `cix search "$ARGUMENTS" --min-score 0.2` to lower the relevance floor +- `cix search "$ARGUMENTS" --in ` to narrow scope + +If `cix` is not yet initialized in this project, run `/cix:init` first. diff --git a/plugins/cix/commands/status.md b/plugins/cix/commands/status.md new file mode 100644 index 0000000..3b9326e --- /dev/null +++ b/plugins/cix/commands/status.md @@ -0,0 +1,15 @@ +--- +description: Show cix indexing status and file-watcher state for the current project +allowed-tools: Bash(cix *) +--- + +Show the current cix indexing status — last sync, number of indexed +files, and whether the file watcher is active. + +```! +cix status +``` + +If `Watcher: ✗ not running`, search results may be stale. Run +`cix watch` to restart the auto-reindex daemon, or `cix reindex` for a +one-off refresh. diff --git a/plugins/cix/commands/summary.md b/plugins/cix/commands/summary.md new file mode 100644 index 0000000..4d1b8b8 --- /dev/null +++ b/plugins/cix/commands/summary.md @@ -0,0 +1,16 @@ +--- +description: Show project overview from the cix index — languages, top directories, key symbols +allowed-tools: Bash(cix *) +--- + +Print a project overview from the cix index — languages, file counts, +top directories, and most-referenced symbols. Useful when starting work +on an unfamiliar codebase. + +```! +cix summary +``` + +Use this output to orient yourself before diving into specific +subsystems. For deeper exploration, follow up with `cix search` on the +top-level concepts you see here. diff --git a/plugins/cix/hooks/hooks.json b/plugins/cix/hooks/hooks.json new file mode 100644 index 0000000..b9474aa --- /dev/null +++ b/plugins/cix/hooks/hooks.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Grep|Glob", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/grep-nudge.sh" + } + ] + } + ] + } +} diff --git a/plugins/cix/scripts/cix-wrapper.sh b/plugins/cix/scripts/cix-wrapper.sh new file mode 100755 index 0000000..28c504c --- /dev/null +++ b/plugins/cix/scripts/cix-wrapper.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# cix CLI wrapper for the Claude Code plugin. +# +# Strategy: "use system cix if available, else bootstrap install via the +# official install.sh script". We do NOT bundle the binary in git or +# maintain a separate cache — install.sh is the single source of truth. +# +# Resolution order: +# 1. If `cix` is found anywhere in PATH (excluding our own dir), +# exec it directly. +# 2. Otherwise, run install.sh with --bin-dir=$HOME/.local/bin +# (no sudo required), then exec the freshly installed binary. + +set -euo pipefail + +# ── Resolve our own directory (real path, dereferencing symlinks) ───────────── +# bin/cix is a symlink to ../scripts/cix-wrapper.sh, so BASH_SOURCE points to +# the real script under scripts/, not the symlink under bin/. We need the +# directory of the symlink (which is what's actually on PATH) — derive it +# from $0 instead, which preserves the invocation path. + +if [ -n "${0:-}" ] && [ "${0:0:1}" = "/" ]; then + INVOKED_PATH="$0" +else + # When called as bare `cix` via PATH, $0 is just "cix" — fall back to + # which/command -v to find ourselves. + INVOKED_PATH="$(command -v "$0" 2>/dev/null || echo "$0")" +fi + +SELF_DIR="$(cd "$(dirname "$INVOKED_PATH")" 2>/dev/null && pwd 2>/dev/null || echo "")" + +# ── Look for a cix binary elsewhere in PATH ─────────────────────────────────── +# Build a "safe PATH" that excludes our own directory so command -v doesn't +# find us recursively. + +SYS_CIX="" +if [ -n "$SELF_DIR" ]; then + SAFE_PATH="" + OLD_IFS="$IFS" + IFS=':' + # shellcheck disable=SC2086 + for dir in $PATH; do + [ -z "$dir" ] && continue + DIR_REAL="$(cd "$dir" 2>/dev/null && pwd 2>/dev/null || echo "$dir")" + if [ "$DIR_REAL" != "$SELF_DIR" ]; then + SAFE_PATH="${SAFE_PATH:+$SAFE_PATH:}$dir" + fi + done + IFS="$OLD_IFS" + SYS_CIX="$(PATH="$SAFE_PATH" command -v cix 2>/dev/null || true)" +else + SYS_CIX="$(command -v cix 2>/dev/null || true)" +fi + +if [ -n "$SYS_CIX" ]; then + exec "$SYS_CIX" "$@" +fi + +# ── Bootstrap install via install.sh (one-time) ─────────────────────────────── +TARGET="${CIX_PLUGIN_BIN_DIR:-$HOME/.local/bin}" +CACHED_CIX="$TARGET/cix" + +if [ ! -x "$CACHED_CIX" ]; then + if ! command -v curl >/dev/null 2>&1; then + echo "Error: cix is not installed and curl is not available to bootstrap it." >&2 + echo "Install cix manually: https://github.com/dvcdsys/code-index" >&2 + exit 1 + fi + + mkdir -p "$TARGET" + echo "cix CLI not found — installing to $TARGET (one-time, no sudo)..." >&2 + + # Use the official install script. Pinned to main; future versions of the + # plugin can pin to a tag (e.g. cli/v0.4.0) for reproducibility. + INSTALL_URL="https://raw.githubusercontent.com/dvcdsys/code-index/main/install.sh" + + if ! curl -fsSL "$INSTALL_URL" | bash -s -- --bin-dir "$TARGET"; then + echo "Error: cix install failed. Check network connectivity and try again." >&2 + echo "You can install manually: curl -fsSL $INSTALL_URL | bash" >&2 + exit 1 + fi + + if [ ! -x "$CACHED_CIX" ]; then + echo "Error: install.sh ran but $CACHED_CIX was not created." >&2 + exit 1 + fi + + echo "cix installed successfully at $CACHED_CIX" >&2 +fi + +exec "$CACHED_CIX" "$@" diff --git a/plugins/cix/scripts/grep-nudge.sh b/plugins/cix/scripts/grep-nudge.sh new file mode 100755 index 0000000..b690b70 --- /dev/null +++ b/plugins/cix/scripts/grep-nudge.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# PreToolUse(Grep|Glob) hook for the cix plugin. +# +# Behavior: when the model is about to invoke Grep or Glob in a project +# that has a cix index, occasionally inject a system reminder pointing +# the model toward `cix search` for semantic queries. +# +# Throttling: exponential backoff. Reminders fire on the 1st, 2nd, 4th, +# 8th, 16th, 32nd, 64th, ... Grep/Glob invocation in the session. +# This means a 100-Grep session sees ~7 reminders total (~560 bytes), +# loud at the start where the model is "learning" the workflow, fading +# to silence as the session wears on. +# +# Per-session counter is kept in /tmp/cix-grep-count-$SESSION_ID. The +# session_id comes from the hook's stdin JSON. +# +# Output: nothing (silent) on non-power-of-2 invocations or in projects +# without .cix/. JSON with hookSpecificOutput.additionalContext on the +# 1st, 2nd, 4th, ... call. + +set -euo pipefail + +# Read JSON input from stdin +INPUT=$(cat 2>/dev/null || echo "{}") + +# Extract session_id (required for state-tracking) +if command -v jq >/dev/null 2>&1; then + SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "") +else + # Crude fallback regex extraction + SESSION_ID=$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) +fi + +# No session_id → can't dedupe, stay silent. +if [ -z "$SESSION_ID" ]; then + exit 0 +fi + +# Check whether the project is cix-indexed. Use only the fast path +# (`.cixignore` at project root) here — this hook fires on every Grep/Glob +# call, so we can't afford to query the cix server. Projects without +# `.cixignore` are silently ignored (false negative is acceptable; better +# than blocking the model's tool call on a network round-trip). +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" +if [ ! -f "$PROJECT_DIR/.cixignore" ]; then + exit 0 +fi + +# Increment per-session counter atomically-enough for our purposes. +# (Race conditions on parallel tool calls are fine — we may emit one +# extra or one fewer reminder, no big deal.) +COUNTER_FILE="/tmp/cix-grep-count-$SESSION_ID" +COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0) +# Sanitize counter (must be a non-negative integer) +case "$COUNT" in + ''|*[!0-9]*) COUNT=0 ;; +esac +COUNT=$((COUNT + 1)) +printf '%d' "$COUNT" > "$COUNTER_FILE" + +# Power-of-2 check: COUNT & (COUNT - 1) == 0 means COUNT is 1, 2, 4, 8, 16, ... +# This implements exponential backoff cleanly. +if [ "$((COUNT & (COUNT - 1)))" -ne 0 ]; then + # Not a power of 2 — stay silent. + exit 0 +fi + +# Build the reminder message. +MESSAGE="💡 You're about to use Grep/Glob (call #$COUNT this session). This project has a cix semantic index — for queries by meaning (find by concept, cross-file lookups, symbol navigation), \`cix search\` / \`cix def\` / \`cix refs\` outperform Grep. Grep is best for exact strings (error messages, config keys, import paths). The \`/cix:search\` slash command is also available." + +# Emit JSON with hookSpecificOutput.additionalContext. +if command -v jq >/dev/null 2>&1; then + jq -n --arg msg "$MESSAGE" \ + '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $msg}}' +else + ESC=$(printf '%s' "$MESSAGE" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ') + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"%s"}}\n' "$ESC" +fi + +exit 0 diff --git a/plugins/cix/scripts/session-start.sh b/plugins/cix/scripts/session-start.sh new file mode 100755 index 0000000..1f6ba15 --- /dev/null +++ b/plugins/cix/scripts/session-start.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# SessionStart hook for the cix plugin. +# +# Behavior: at session start, if the current project has a cix index +# (.cix/ directory present), inject a small system reminder into Claude's +# context telling it that semantic search is available. +# +# This is the "initial nudge" layer — fires exactly once per session, +# never repeats. The PreToolUse(Grep|Glob) hook handles ongoing +# anti-Grep nudges with exponential backoff. +# +# Output: nothing if no .cix/ exists; a JSON object with +# `hookSpecificOutput.additionalContext` otherwise. + +set -euo pipefail + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +# Detect whether this project is cix-indexed. +# Priority order: +# 1. `.cixignore` exists at project root (typical for indexed projects +# that need to ignore node_modules / build dirs) — fast path. +# 2. `cix list` shows this project as registered on the server — slower +# fallback that runs only at session start. +is_cix_project() { + if [ -f "$PROJECT_DIR/.cixignore" ]; then + return 0 + fi + + # Fallback: query cix server. Use plugin-bundled wrapper first, + # then fall back to system PATH. Skip silently if neither is available. + local cix_bin="" + if [ -x "${CLAUDE_PLUGIN_ROOT:-}/bin/cix" ]; then + cix_bin="${CLAUDE_PLUGIN_ROOT}/bin/cix" + elif command -v cix >/dev/null 2>&1; then + cix_bin="$(command -v cix)" + fi + + if [ -n "$cix_bin" ]; then + # `cix list` is cheap (~100ms); grep for our project path. + if "$cix_bin" list 2>/dev/null | grep -qF "$PROJECT_DIR"; then + return 0 + fi + fi + + return 1 +} + +if ! is_cix_project; then + exit 0 +fi + +# We have an index. Inject a short reminder. +MESSAGE='💡 This project has a cix semantic code index. For semantic queries — finding code by meaning, cross-file lookups, symbol navigation, "where is X used", "how does Y work" — prefer `cix search`, `cix def`, `cix refs`, or the slash commands `/cix:search`, `/cix:def`, `/cix:refs`. Use Grep only for exact strings (error messages, config keys, import paths). Run `cix status` if results seem stale.' + +# Emit JSON with hookSpecificOutput.additionalContext. +# jq is preferred for safe escaping; fall back to manual escaping if absent. +if command -v jq >/dev/null 2>&1; then + jq -n --arg msg "$MESSAGE" \ + '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $msg}}' +else + # Crude fallback — escape backslashes, quotes, and newlines. + ESC=$(printf '%s' "$MESSAGE" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ') + printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$ESC" +fi + +exit 0 diff --git a/plugins/cix/skills/cix/SKILL.md b/plugins/cix/skills/cix/SKILL.md new file mode 100644 index 0000000..13ea488 --- /dev/null +++ b/plugins/cix/skills/cix/SKILL.md @@ -0,0 +1,217 @@ +--- +name: cix +description: Semantic code search and navigation via the cix index. Use this when finding code by meaning rather than exact strings — cross-file lookups, symbol navigation, "where is X used", "how does Y work", "find authentication middleware", or exploring an unfamiliar codebase. Covers search, definitions, references, symbol search, file lookup, and indexing. +when_to_use: | + Trigger this skill when the user asks anything that requires semantic understanding of the codebase: + - "find authentication middleware" / "find the auth code" + - "where is X defined?" / "show me the definition of Y" + - "how does Z work in this codebase?" + - "what calls this function?" / "find references to ..." + - "search the codebase for ..." / "find by meaning" + - "explore this repo" / "give me an overview" + - Any time you would otherwise reach for Grep on a non-literal query + + Skip this skill (use Grep / Read instead) when: + - A stack trace or error already names file:line — just Read it + - Searching for an exact literal (specific error string, config key name, import path) + - Inside dependencies (node_modules, vendor, .venv) — they aren't indexed + - Editing a non-code file (Dockerfile, yaml, lockfile) +user-invocable: true +allowed-tools: Bash(cix *) +--- + +# Code Index (`cix`) — Semantic Code Search & Navigation + +You have access to `cix`, a semantic code index that understands the +codebase via embeddings + AST parsing. The right reflex is **"cix when +you don't have a pointer; grep when you do."** + +This plugin also exposes shortcuts: `/cix:search`, `/cix:def`, `/cix:refs`, +`/cix:init`, `/cix:status`, `/cix:summary`. The `cix` CLI is bundled — +the plugin auto-installs it on first use if your system doesn't have it. + +## When to use which + +**Reach for `cix` first when:** +- The starting point is open-ended ("how does indexing work?", "find the + authentication middleware", "where is the main entry point?") +- You need cross-file navigation (definitions / references / callers) +- You're searching by *meaning*, not by an exact string + (`"JWT validation"` should find `verifyToken` even without that phrase) +- You're exploring an unfamiliar package or codebase + +**Skip `cix`, use Read / Grep / Glob directly when:** +- A failing test or stack trace already names the file and function — + just `Read` it +- You're chasing an exact literal: a specific error message, a config + key, a commit-message phrase, an import path +- You're inside dependencies (`node_modules`, `vendor`, `.venv`) — they + aren't indexed +- You're editing a non-code file (Dockerfile, yaml, lockfile) + +If `cix` returns nothing relevant after one well-formed query, fall +back to grep — don't loop on cix. + +--- + +## Commands Reference + +### Semantic Search — find code by meaning +```bash +cix search "authentication middleware" +cix search "database connection retry logic" +cix search "error handling in payment flow" --limit 20 +cix search "config parsing" --in ./internal/config/ +cix search "API routes" --lang go +cix search "main entry point" --exclude bench/fixtures --exclude legacy +``` + +**Flags:** +- `--in ` — restrict to file or directory (can repeat) +- `--exclude ` — drop a directory or substring from results (can repeat) +- `--lang ` — filter by language (can repeat) +- `--limit ` — max **files** returned (default: 10) — output is + grouped per file with all matches inside, so 10 files ≈ many snippets +- `--min-score ` — minimum relevance 0.0–1.0 (default: **0.4**) + +### Go to Definition — find where a symbol is defined +```bash +cix definitions HandleRequest +cix def AuthMiddleware --kind function +cix def Config --file ./internal/config.go +``` +Aliases: `definitions`, `def`, `goto`. Flags: `--kind`, `--file`, `--limit`. + +### Find References — find where a symbol is used +```bash +cix references HandleRequest +cix refs AuthMiddleware --limit 50 +cix usages UserService --file ./internal/api/ +``` +Aliases: `references`, `refs`, `usages`. Flags: `--file`, `--limit`. + +### Symbol Search — find symbols by name +```bash +cix symbols handleRequest +cix symbols User --kind class +cix symbols Auth --kind function --kind method +``` +Flags: `--kind` (function/class/method/type, repeatable), `--limit`. + +### File Search — find files by path pattern +```bash +cix files "config" +cix files "middleware" --limit 20 +``` + +### Project Overview +```bash +cix summary # languages, top dirs, key symbols +cix status # indexing status + file watcher status +cix list # all indexed projects +``` + +### Indexing +```bash +cix init [path] # register + index + start watcher +cix reindex # incremental +cix reindex --full # full reindex +cix cancel # cancel an in-flight indexing run +cix watch # start file-change auto-reindex daemon +cix watch stop # stop daemon +``` + +The watcher auto-reindexes on file change — manual `reindex` is rarely +needed. `cix status` shows whether the watcher is running and the +last-sync timestamp. + +--- + +## Search quality — what scores mean + +Default `--min-score 0.4` is calibrated for the production embedding +model (CodeRankEmbed-Q8 with path-aware preamble). Rough landscape: + +| Score | Meaning | +|----------|---------------------------------------------------------| +| 0.65+ | Exact / very strong match — almost certainly relevant | +| 0.50–0.65| Strong match — usually relevant | +| 0.40–0.50| Weaker match — sometimes useful, sometimes not | +| <0.40 | Noise — filtered out by default | + +**If a query returns nothing**, lower the floor explicitly: +`--min-score 0.2` for very specific or long-tail queries. Don't drop +below 0.2 — results below that are noise. + +--- + +## Writing better queries — leverage path-aware embedding + +Each chunk is embedded with its file path, language, and symbol name in +the preamble. This means **mentioning a file/dir/symbol you already +know about boosts ranking**: + +```bash +# Generic +cix search "validation" +# Better — pins the search to the auth area +cix search "validation in auth middleware" +# Even better when you know the symbol +cix search "ValidateToken" --kind function +``` + +Natural-language queries that name the *kind of thing* and *where it +lives* outperform single-word queries. + +--- + +## Usage Patterns + +### Exploring unfamiliar code (`cix`'s strongest case) +```bash +cix summary # project structure, top dirs +cix search "main entry point server" # find where it starts +cix search "database connection setup" # find DB wiring +cix search "request handler" --in ./api # narrow to API +``` + +### Tracing a symbol end-to-end +```bash +cix def HandleRequest # where is it defined? +cix refs HandleRequest # who calls it? +cix search "HandleRequest error handling" # how are errors handled? +``` + +### Chasing a known target (often grep is enough) +```bash +# Stack trace says "internal/auth/middleware.go:42 — invalid token" +# → just Read that file. No cix needed. + +# Config key "max_concurrent_requests" used somewhere? +# → grep is more precise. +``` + +### Narrowing scope +```bash +cix search "middleware" --in ./api/ +cix search "config" --in ./cmd/ --exclude legacy +cix refs Config --file ./internal/server.go +``` + +--- + +## Tips + +- Search queries are natural language, not regex. Write what you'd ask + a colleague. +- Output groups by file: each result line is a file with all relevant + matches inside, ordered top-to-bottom by line number. The + `[best 0.NN]` is the score of the top hit in that file. +- `cix def` is a faster path than `cix symbols` when you already know + the exact name. +- `--exclude` complements `--in` — use it to drop noisy dirs (`bench/`, + `legacy/`, vendored code) inline without touching `.cixignore`. +- The watcher keeps the index fresh. If results feel stale, check + `cix status` first — `Watcher: ✗ not running` is the usual cause. +- Don't loop. If a query returns nothing useful after one well-phrased + attempt + one `--min-score 0.2` retry, drop to grep. From 7d63c08b1f999de64ae6c7348058bdafd843c3b8 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Fri, 8 May 2026 22:40:01 +0100 Subject: [PATCH 2/9] fix(plugin): use cix status (authoritative) for indexed-project detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously hooks used the presence of `.cixignore` as a proxy for "this project is cix-indexed". That's a weak signal — a project may be registered with the cix-server without a `.cixignore` file (it's optional, only needed for ignore patterns), and a stale `.cixignore` can stick around after a project is removed from the server. Switch SessionStart to ask `cix status` directly (~17 ms on a local server, exit 0 only if the project is registered AND the server is reachable). Cache the decision in `/tmp/cix-aware-$SESSION_ID` so PreToolUse(Grep|Glob) can read it in ~1 ms without re-querying on every Grep call. session-start.sh: - Resolves cix binary (plugin-bundled wrapper preferred, falls back to PATH lookup). - Runs `cix status -p $CLAUDE_PROJECT_DIR` with a 2-second timeout implemented in pure bash (no coreutils dependency — macOS lacks `timeout`/`gtimeout` by default). - Writes "1" or "0" to /tmp/cix-aware-$SESSION_ID. - Injects the same SessionStart reminder on success, stays silent on failure (project not indexed, server unreachable, cix CLI missing). grep-nudge.sh: - Reads /tmp/cix-aware-$SESSION_ID first. - Falls back to .cixignore presence if the cache is absent (covers resumed sessions where SessionStart didn't fire). - Stays silent if neither source confirms cix-aware. No cix CLI calls. Docs (CLAUDE-CODE-PLUGIN.md, plugins/cix/README.md): - "How it works" / "Per-project trigger threshold" / "Hooks silent in indexed project" troubleshooting all reflect the new logic. - Document the cache files (cix-aware + cix-grep-count) and their cleanup on /tmp reboot. End-to-end test passed: SessionStart writes cache=1 in indexed repo, cache=0 in /tmp; grep-nudge fires on calls 1, 2, 4, 8 reading the cache in indexed repo, stays silent in /tmp. Co-Authored-By: Claude Opus 4.7 --- CLAUDE-CODE-PLUGIN.md | 53 ++++++++---- plugins/cix/README.md | 28 ++++--- plugins/cix/scripts/grep-nudge.sh | 61 +++++++------- plugins/cix/scripts/session-start.sh | 117 ++++++++++++++++++--------- 4 files changed, 165 insertions(+), 94 deletions(-) diff --git a/CLAUDE-CODE-PLUGIN.md b/CLAUDE-CODE-PLUGIN.md index 0875829..bf02254 100644 --- a/CLAUDE-CODE-PLUGIN.md +++ b/CLAUDE-CODE-PLUGIN.md @@ -30,16 +30,21 @@ project automatically gets: by Claude Code — enters context only when invoked, stays once per session. - **Behavioral hooks:** - - **`SessionStart`** — at session start, if the project is cix-indexed - (has `.cixignore`), injects a one-line reminder telling Claude that - semantic search is available. - - **`PreToolUse(Grep|Glob)`** — when Claude is about to use Grep/Glob - in a cix-indexed project, occasionally suggests `cix search` instead. + - **`SessionStart`** — at session start, runs `cix status` (with a + 2-second timeout) to ask the cix-server whether the current + project is registered. If yes, injects a one-line reminder telling + Claude that semantic search is available, and caches the decision + in `/tmp/cix-aware-$SESSION_ID`. + - **`PreToolUse(Grep|Glob)`** — when Claude is about to use Grep/Glob, + reads the SessionStart cache (no `cix` call here, ~1 ms) and, if + the project is indexed, occasionally suggests `cix search` instead. Throttled with **exponential backoff** (fires on call #1, 2, 4, 8, 16, 32, 64, …) so the reminder doesn't become noise — at most ~7 - nudges per 100-Grep session. + nudges per 100-Grep session. Falls back to checking `.cixignore` + if the cache is missing (e.g. resumed session). -In projects **without** a cix index, all hooks are silent — the plugin +In projects **without** a cix index (or when the cix-server is +unreachable at session start), all hooks are silent — the plugin adds zero context overhead. --- @@ -269,16 +274,24 @@ the plugin doesn't write to it. Configure the CLI once (see ### Per-project trigger threshold -The plugin only nudges Claude in projects with a `.cixignore` file at -the project root. If your project is indexed but doesn't have one, -create an empty `.cixignore`: +The plugin nudges Claude in projects that `cix status` reports as +**indexed**. The check runs once per session at SessionStart (against +the cix-server) and is cached for the remainder of the session. If the +cix-server is unreachable at session start, the plugin treats the +project as not-indexed and stays silent for the whole session. + +For resumed sessions where SessionStart didn't run, the plugin falls +back to checking for a `.cixignore` file at the project root. If you +want a "manual" trigger (e.g. on a host where the cix-server is +intermittent and you want the nudges anyway), drop an empty +`.cixignore`: ```bash touch .cixignore ``` -This is also the recommended way to give cix per-project ignore patterns -(it follows `.gitignore` syntax). +This file also follows `.gitignore` syntax and is the recommended way +to give cix per-project ignore patterns. --- @@ -316,14 +329,22 @@ If the symlink is missing, reinstall: ### Hooks silent in indexed project -The hooks gate on `.cixignore` at the project root. Verify: +The hooks rely on `cix status` succeeding at session start. Verify: ```bash -ls -la $(pwd)/.cixignore +cix status -p $(pwd) # must exit 0 +echo "exit=$?" ``` -If it's missing but the project IS indexed, create an empty one -(`touch .cixignore`) and restart your Claude Code session. +If `cix status` fails: +- Server unreachable: `curl http://localhost:21847/health` +- API key not set: `cix config show` +- Project not registered: `cix init` + +If `cix status` succeeds but hooks are still silent, restart your +Claude Code session — SessionStart cached "not indexed" earlier and +won't re-query until next session. As a workaround, drop an empty +`.cixignore` at the project root for the fallback path to fire. ### Hooks too loud / too quiet diff --git a/plugins/cix/README.md b/plugins/cix/README.md index 3c51c5f..791e467 100644 --- a/plugins/cix/README.md +++ b/plugins/cix/README.md @@ -19,14 +19,16 @@ Semantic code search and navigation for Claude Code, powered by the prompt). Stays in context for the rest of the session — never duplicated. - **Behavioral nudges (hooks):** - - **SessionStart** — at session start, if the current project has a - `.cix/` index, injects a one-line reminder telling Claude that - semantic search is available. - - **PreToolUse(Grep|Glob)** — when Claude is about to use Grep/Glob - in an indexed project, occasionally suggests `cix search` instead. - Throttled with **exponential backoff** (fires on call #1, 2, 4, 8, - 16, 32, 64, …) so the reminder doesn't become noise — at most ~7 - nudges per 100-Grep session. + - **SessionStart** — at session start, calls `cix status` (with a 2 s + timeout) to ask whether the project is registered with the + cix-server. On success, injects a one-line reminder and caches the + decision in `/tmp/cix-aware-$SESSION_ID` for the rest of the session. + - **PreToolUse(Grep|Glob)** — reads the SessionStart cache (no `cix` + re-query) and, if the project is indexed, occasionally suggests + `cix search` instead. Throttled with **exponential backoff** (fires + on call #1, 2, 4, 8, 16, 32, 64, …) — at most ~7 nudges per 100-Grep + session. Falls back to checking `.cixignore` if the cache is missing + (resumed session). ## Install @@ -99,9 +101,13 @@ enabling the plugin. ### Hook state cleanup -The PreToolUse hook keeps a per-session counter at -`/tmp/cix-grep-count-$SESSION_ID`. These are zero-byte markers that -`/tmp` cleans up on reboot. No manual cleanup needed. +Two per-session cache files live in `/tmp`: +- `/tmp/cix-aware-$SESSION_ID` — written by SessionStart, read by + PreToolUse. Single-byte file (`0` or `1`). +- `/tmp/cix-grep-count-$SESSION_ID` — counter for the exponential + backoff. A few bytes. + +`/tmp` is cleaned on reboot, so no manual cleanup is needed. ## Files diff --git a/plugins/cix/scripts/grep-nudge.sh b/plugins/cix/scripts/grep-nudge.sh index b690b70..c1c8555 100755 --- a/plugins/cix/scripts/grep-nudge.sh +++ b/plugins/cix/scripts/grep-nudge.sh @@ -2,73 +2,78 @@ # PreToolUse(Grep|Glob) hook for the cix plugin. # # Behavior: when the model is about to invoke Grep or Glob in a project -# that has a cix index, occasionally inject a system reminder pointing -# the model toward `cix search` for semantic queries. +# that the SessionStart hook flagged as cix-indexed, occasionally inject +# a system reminder pointing toward `cix search`. # # Throttling: exponential backoff. Reminders fire on the 1st, 2nd, 4th, # 8th, 16th, 32nd, 64th, ... Grep/Glob invocation in the session. -# This means a 100-Grep session sees ~7 reminders total (~560 bytes), -# loud at the start where the model is "learning" the workflow, fading -# to silence as the session wears on. +# A 100-Grep session sees ~7 reminders total (~560 bytes), loud at the +# start where the model is "learning" the workflow, fading to silence +# as the session wears on. # -# Per-session counter is kept in /tmp/cix-grep-count-$SESSION_ID. The -# session_id comes from the hook's stdin JSON. +# Project detection: read /tmp/cix-aware-$SESSION_ID, written by +# session-start.sh after a successful `cix status` query. We do NOT +# call `cix status` ourselves here — this hook fires on every Grep/Glob +# call, so re-querying the server would be wasteful. Falls back to +# checking `.cixignore` if the cache file is missing (e.g. session +# resumed without re-running SessionStart). # -# Output: nothing (silent) on non-power-of-2 invocations or in projects -# without .cix/. JSON with hookSpecificOutput.additionalContext on the -# 1st, 2nd, 4th, ... call. +# Output: nothing on non-power-of-2 invocations or in non-indexed +# projects. JSON with hookSpecificOutput.additionalContext on the 1st, +# 2nd, 4th, ... call. set -euo pipefail # Read JSON input from stdin INPUT=$(cat 2>/dev/null || echo "{}") -# Extract session_id (required for state-tracking) if command -v jq >/dev/null 2>&1; then SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "") else - # Crude fallback regex extraction SESSION_ID=$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) fi -# No session_id → can't dedupe, stay silent. +# No session_id → can't dedupe nor read cache, stay silent. if [ -z "$SESSION_ID" ]; then exit 0 fi -# Check whether the project is cix-indexed. Use only the fast path -# (`.cixignore` at project root) here — this hook fires on every Grep/Glob -# call, so we can't afford to query the cix server. Projects without -# `.cixignore` are silently ignored (false negative is acceptable; better -# than blocking the model's tool call on a network round-trip). PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" -if [ ! -f "$PROJECT_DIR/.cixignore" ]; then + +# ── Decide whether project is cix-aware ─────────────────────────────────────── +# Primary source: cache written by session-start.sh. +# Fallback: presence of .cixignore (less authoritative but doesn't require +# the SessionStart hook to have run, useful for resumed sessions). +CACHE_FILE="/tmp/cix-aware-$SESSION_ID" +IS_AWARE=0 + +if [ -f "$CACHE_FILE" ]; then + [ "$(cat "$CACHE_FILE" 2>/dev/null)" = "1" ] && IS_AWARE=1 +elif [ -f "$PROJECT_DIR/.cixignore" ]; then + IS_AWARE=1 +fi + +if [ "$IS_AWARE" != "1" ]; then exit 0 fi -# Increment per-session counter atomically-enough for our purposes. -# (Race conditions on parallel tool calls are fine — we may emit one -# extra or one fewer reminder, no big deal.) +# ── Increment per-session counter ───────────────────────────────────────────── COUNTER_FILE="/tmp/cix-grep-count-$SESSION_ID" COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0) -# Sanitize counter (must be a non-negative integer) case "$COUNT" in ''|*[!0-9]*) COUNT=0 ;; esac COUNT=$((COUNT + 1)) printf '%d' "$COUNT" > "$COUNTER_FILE" -# Power-of-2 check: COUNT & (COUNT - 1) == 0 means COUNT is 1, 2, 4, 8, 16, ... -# This implements exponential backoff cleanly. +# Power-of-2 check: COUNT & (COUNT - 1) == 0 means COUNT is 1, 2, 4, 8, ... if [ "$((COUNT & (COUNT - 1)))" -ne 0 ]; then - # Not a power of 2 — stay silent. exit 0 fi -# Build the reminder message. +# ── Emit nudge ──────────────────────────────────────────────────────────────── MESSAGE="💡 You're about to use Grep/Glob (call #$COUNT this session). This project has a cix semantic index — for queries by meaning (find by concept, cross-file lookups, symbol navigation), \`cix search\` / \`cix def\` / \`cix refs\` outperform Grep. Grep is best for exact strings (error messages, config keys, import paths). The \`/cix:search\` slash command is also available." -# Emit JSON with hookSpecificOutput.additionalContext. if command -v jq >/dev/null 2>&1; then jq -n --arg msg "$MESSAGE" \ '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $msg}}' diff --git a/plugins/cix/scripts/session-start.sh b/plugins/cix/scripts/session-start.sh index 1f6ba15..11609b7 100755 --- a/plugins/cix/scripts/session-start.sh +++ b/plugins/cix/scripts/session-start.sh @@ -1,65 +1,104 @@ #!/usr/bin/env bash # SessionStart hook for the cix plugin. # -# Behavior: at session start, if the current project has a cix index -# (.cix/ directory present), inject a small system reminder into Claude's -# context telling it that semantic search is available. +# Behavior: at session start, ask `cix status` whether the current +# project is indexed. If yes, inject a one-line system reminder into +# Claude's context and cache the "is indexed" decision in +# /tmp/cix-aware-$SESSION_ID so the PreToolUse hook can short-circuit +# without re-querying the server. # -# This is the "initial nudge" layer — fires exactly once per session, -# never repeats. The PreToolUse(Grep|Glob) hook handles ongoing -# anti-Grep nudges with exponential backoff. +# Why `cix status` and not `.cixignore`: +# - Authoritative: queries the cix-server, returns exit 0 only if the +# project is registered AND reachable at session start. +# - Fast: ~17ms on a local server (HTTP + SQLite lookup). +# - Robust: works for projects that don't keep a `.cixignore` file. # -# Output: nothing if no .cix/ exists; a JSON object with -# `hookSpecificOutput.additionalContext` otherwise. +# Output: nothing if the project isn't indexed (or cix isn't available); +# a JSON object with `hookSpecificOutput.additionalContext` otherwise. +# +# Cache contract (read by grep-nudge.sh): +# /tmp/cix-aware-$SESSION_ID exists with content "1" → project is indexed +# File absent or content "0" → not indexed, stay silent set -euo pipefail +# ── Read session_id from stdin JSON (Claude Code provides it) ───────────────── +INPUT=$(cat 2>/dev/null || echo "{}") +if command -v jq >/dev/null 2>&1; then + SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "") +else + SESSION_ID=$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) +fi + PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" +CACHE_FILE="" +if [ -n "$SESSION_ID" ]; then + CACHE_FILE="/tmp/cix-aware-$SESSION_ID" +fi + +# ── Resolve a working `cix` binary ──────────────────────────────────────────── +# Prefer the plugin-bundled wrapper, fall back to whatever is on PATH. +CIX_BIN="" +if [ -x "${CLAUDE_PLUGIN_ROOT:-}/bin/cix" ]; then + CIX_BIN="${CLAUDE_PLUGIN_ROOT}/bin/cix" +elif command -v cix >/dev/null 2>&1; then + CIX_BIN="$(command -v cix)" +fi -# Detect whether this project is cix-indexed. -# Priority order: -# 1. `.cixignore` exists at project root (typical for indexed projects -# that need to ignore node_modules / build dirs) — fast path. -# 2. `cix list` shows this project as registered on the server — slower -# fallback that runs only at session start. -is_cix_project() { - if [ -f "$PROJECT_DIR/.cixignore" ]; then - return 0 - fi +if [ -z "$CIX_BIN" ]; then + # No cix CLI yet (would be auto-installed on first call). Stay silent. + [ -n "$CACHE_FILE" ] && printf '0' > "$CACHE_FILE" + exit 0 +fi + +# ── Run `cix status` with a 2-second timeout (no coreutils dependency) ──────── +# We background the call, then wait up to 2s for it to finish. If it doesn't, +# kill it and treat as "not indexed". +( + "$CIX_BIN" status -p "$PROJECT_DIR" >/dev/null 2>&1 + echo "$?" > "${CACHE_FILE:-/dev/null}.exit" 2>/dev/null || true +) & +CIX_PID=$! + +# Poll for completion up to 20 × 100ms = 2s +SLEPT=0 +while kill -0 "$CIX_PID" 2>/dev/null && [ "$SLEPT" -lt 20 ]; do + sleep 0.1 + SLEPT=$((SLEPT + 1)) +done - # Fallback: query cix server. Use plugin-bundled wrapper first, - # then fall back to system PATH. Skip silently if neither is available. - local cix_bin="" - if [ -x "${CLAUDE_PLUGIN_ROOT:-}/bin/cix" ]; then - cix_bin="${CLAUDE_PLUGIN_ROOT}/bin/cix" - elif command -v cix >/dev/null 2>&1; then - cix_bin="$(command -v cix)" - fi +if kill -0 "$CIX_PID" 2>/dev/null; then + # Still running — kill it and treat as not indexed. + kill -9 "$CIX_PID" 2>/dev/null || true + wait "$CIX_PID" 2>/dev/null || true + [ -n "$CACHE_FILE" ] && printf '0' > "$CACHE_FILE" + exit 0 +fi - if [ -n "$cix_bin" ]; then - # `cix list` is cheap (~100ms); grep for our project path. - if "$cix_bin" list 2>/dev/null | grep -qF "$PROJECT_DIR"; then - return 0 - fi - fi +wait "$CIX_PID" 2>/dev/null || true - return 1 -} +# Read the captured exit code (file written by the backgrounded subshell). +EXIT_CODE=1 +if [ -n "$CACHE_FILE" ] && [ -f "${CACHE_FILE}.exit" ]; then + EXIT_CODE=$(cat "${CACHE_FILE}.exit" 2>/dev/null || echo 1) + rm -f "${CACHE_FILE}.exit" +fi -if ! is_cix_project; then +if [ "$EXIT_CODE" != "0" ]; then + # Not indexed (or cix-server unreachable). Stay silent. + [ -n "$CACHE_FILE" ] && printf '0' > "$CACHE_FILE" exit 0 fi -# We have an index. Inject a short reminder. +# ── Project IS indexed — cache + inject reminder ────────────────────────────── +[ -n "$CACHE_FILE" ] && printf '1' > "$CACHE_FILE" + MESSAGE='💡 This project has a cix semantic code index. For semantic queries — finding code by meaning, cross-file lookups, symbol navigation, "where is X used", "how does Y work" — prefer `cix search`, `cix def`, `cix refs`, or the slash commands `/cix:search`, `/cix:def`, `/cix:refs`. Use Grep only for exact strings (error messages, config keys, import paths). Run `cix status` if results seem stale.' -# Emit JSON with hookSpecificOutput.additionalContext. -# jq is preferred for safe escaping; fall back to manual escaping if absent. if command -v jq >/dev/null 2>&1; then jq -n --arg msg "$MESSAGE" \ '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $msg}}' else - # Crude fallback — escape backslashes, quotes, and newlines. ESC=$(printf '%s' "$MESSAGE" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ') printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$ESC" fi From 770103c6a22984b628f31c480240f3945fa695ef Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Fri, 8 May 2026 22:44:37 +0100 Subject: [PATCH 3/9] fix(plugin): strict cache contract + persistent storage in $CLAUDE_PLUGIN_DATA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes addressing review feedback: 1. Move cache from /tmp to $CLAUDE_PLUGIN_DATA - /tmp on macOS is cleaned daily for files older than 3 days; on Linux it's typically cleared on reboot. Long-running Claude Code sessions outlived these windows, so the cache could vanish mid-session and force grep-nudge into its fallback path (now removed — see #2). - $CLAUDE_PLUGIN_DATA is plugin-persistent storage managed by Claude Code (resolves to ~/.claude/plugins/data/cix-code-index/), is exported to hook subprocesses, survives plugin updates, and is never cleaned by the OS. - SessionStart opportunistically deletes its own markers older than 30 days so the directory doesn't grow unbounded. - Falls back to /tmp only when run outside a plugin context (tests). 2. Strict cache contract — no .cixignore fallback - Per user feedback: "if SessionStart concluded the project is not indexed, just turn off — don't pollute the agent with reminders". - grep-nudge previously fell back to checking .cixignore when the cache file was absent. That created false positives: a stale .cixignore left over from a removed project would trigger nudges even after `cix status` confirmed not-indexed. - New contract: cache file present + content "1" → nudges allowed; anything else (file missing, "0", or unreadable) → silent for the entire session. - Trade-off documented: a session that started before the cix-server came up stays silent even after the server recovers. Restart Claude Code to re-evaluate. Better to miss a few nudge opportunities than to nag a developer whose server is down. Test coverage: end-to-end script tests pass for all three states — indexed (NUDGE on calls 1, 2, 4, 8), not-indexed (silent), and cache-absent (silent without .cixignore fallback). Docs (CLAUDE-CODE-PLUGIN.md, plugins/cix/README.md): - "How it works" section explains the strict cache policy. - "Per-project trigger threshold" documents that a server reachable at SessionStart is required, and how to recover (restart session). - "Hook state cleanup" section now points at $CLAUDE_PLUGIN_DATA and explains the 30-day GC. - Troubleshooting "Hooks silent in indexed project" lists the inspection command for the cache file. Co-Authored-By: Claude Opus 4.7 --- CLAUDE-CODE-PLUGIN.md | 77 ++++++++++++++----------- plugins/cix/README.md | 32 ++++++----- plugins/cix/scripts/grep-nudge.sh | 59 +++++++------------ plugins/cix/scripts/session-start.sh | 85 ++++++++++++++++------------ 4 files changed, 130 insertions(+), 123 deletions(-) diff --git a/CLAUDE-CODE-PLUGIN.md b/CLAUDE-CODE-PLUGIN.md index bf02254..8f614b4 100644 --- a/CLAUDE-CODE-PLUGIN.md +++ b/CLAUDE-CODE-PLUGIN.md @@ -32,20 +32,29 @@ project automatically gets: - **Behavioral hooks:** - **`SessionStart`** — at session start, runs `cix status` (with a 2-second timeout) to ask the cix-server whether the current - project is registered. If yes, injects a one-line reminder telling - Claude that semantic search is available, and caches the decision - in `/tmp/cix-aware-$SESSION_ID`. - - **`PreToolUse(Grep|Glob)`** — when Claude is about to use Grep/Glob, - reads the SessionStart cache (no `cix` call here, ~1 ms) and, if - the project is indexed, occasionally suggests `cix search` instead. - Throttled with **exponential backoff** (fires on call #1, 2, 4, 8, - 16, 32, 64, …) so the reminder doesn't become noise — at most ~7 - nudges per 100-Grep session. Falls back to checking `.cixignore` - if the cache is missing (e.g. resumed session). - -In projects **without** a cix index (or when the cix-server is -unreachable at session start), all hooks are silent — the plugin -adds zero context overhead. + project is registered. The result (`1` or `0`) is cached in + `$CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID` for the rest of the + session. On `1`, injects a one-line reminder telling Claude that + semantic search is available; on `0`, stays silent. + - **`PreToolUse(Grep|Glob)`** — reads the SessionStart cache (~1 ms, + no cix call). If the cache says `1`, occasionally suggests + `cix search` instead of Grep, throttled with **exponential backoff** + (fires on call #1, 2, 4, 8, 16, 32, 64, …) — at most ~7 nudges per + 100-Grep session. **Strict policy:** if the cache is missing or + says `0`, the hook stays silent for the entire session. No + `.cixignore` fallback, no inline `cix status` retries. + +The strict cache contract means: a session that started while the +cix-server was unreachable will stay in "silent" mode even if the +server comes back online. That's intentional — we'd rather miss a +few nudge opportunities than pester a developer whose server is down. +Restart the Claude Code session to re-evaluate. + +State location: `$CLAUDE_PLUGIN_DATA` is plugin-persistent storage +(resolves to `~/.claude/plugins/data/cix-code-index/`) — it survives +plugin updates and is **not** cleaned by the OS, unlike `/tmp` (macOS +purges 3-day-old files daily; Linux clears on reboot). SessionStart +also opportunistically deletes its own markers older than 30 days. --- @@ -276,22 +285,14 @@ the plugin doesn't write to it. Configure the CLI once (see The plugin nudges Claude in projects that `cix status` reports as **indexed**. The check runs once per session at SessionStart (against -the cix-server) and is cached for the remainder of the session. If the -cix-server is unreachable at session start, the plugin treats the -project as not-indexed and stays silent for the whole session. +the cix-server) and is cached for the remainder of the session. +PreToolUse(Grep|Glob) only ever reads the cache — it never makes its +own `cix` calls. -For resumed sessions where SessionStart didn't run, the plugin falls -back to checking for a `.cixignore` file at the project root. If you -want a "manual" trigger (e.g. on a host where the cix-server is -intermittent and you want the nudges anyway), drop an empty -`.cixignore`: - -```bash -touch .cixignore -``` - -This file also follows `.gitignore` syntax and is the recommended way -to give cix per-project ignore patterns. +If the cix-server is unreachable at session start, the project is +locked into "silent" mode for the rest of the session. Restart Claude +Code (Cmd+Q + reopen, or `/reload` in CLI) once the server is back to +re-evaluate. --- @@ -329,7 +330,7 @@ If the symlink is missing, reinstall: ### Hooks silent in indexed project -The hooks rely on `cix status` succeeding at session start. Verify: +The hooks rely on `cix status` succeeding at SessionStart. Verify: ```bash cix status -p $(pwd) # must exit 0 @@ -341,10 +342,18 @@ If `cix status` fails: - API key not set: `cix config show` - Project not registered: `cix init` -If `cix status` succeeds but hooks are still silent, restart your -Claude Code session — SessionStart cached "not indexed" earlier and -won't re-query until next session. As a workaround, drop an empty -`.cixignore` at the project root for the fallback path to fire. +Once `cix status` exits 0, **restart the Claude Code session** — +SessionStart cached the previous "not indexed" verdict and the +PreToolUse hook reads only that cache. There's no inline retry by +design (a flaky server shouldn't cause intermittent nudges). + +To inspect the current verdict from outside Claude Code: + +```bash +ls -la ~/.claude/plugins/data/cix-code-index/cix-aware-* +cat ~/.claude/plugins/data/cix-code-index/cix-aware- +# "1" → nudges allowed; "0" → silent +``` ### Hooks too loud / too quiet diff --git a/plugins/cix/README.md b/plugins/cix/README.md index 791e467..5a6a355 100644 --- a/plugins/cix/README.md +++ b/plugins/cix/README.md @@ -19,16 +19,15 @@ Semantic code search and navigation for Claude Code, powered by the prompt). Stays in context for the rest of the session — never duplicated. - **Behavioral nudges (hooks):** - - **SessionStart** — at session start, calls `cix status` (with a 2 s - timeout) to ask whether the project is registered with the - cix-server. On success, injects a one-line reminder and caches the - decision in `/tmp/cix-aware-$SESSION_ID` for the rest of the session. - - **PreToolUse(Grep|Glob)** — reads the SessionStart cache (no `cix` - re-query) and, if the project is indexed, occasionally suggests - `cix search` instead. Throttled with **exponential backoff** (fires - on call #1, 2, 4, 8, 16, 32, 64, …) — at most ~7 nudges per 100-Grep - session. Falls back to checking `.cixignore` if the cache is missing - (resumed session). + - **SessionStart** — calls `cix status` (2 s timeout). Caches the + yes/no verdict in `$CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID`, + injects a one-line reminder on success, stays silent on failure. + - **PreToolUse(Grep|Glob)** — reads the SessionStart cache only; no + inline `cix` calls. If the verdict is "yes" (`1`), suggests + `cix search` instead of Grep, throttled with exponential backoff + (fires on call #1, 2, 4, 8, 16, …). If the verdict is "no" (`0`) + or missing, **stays silent for the entire session** — by design, + so a flaky server doesn't cause intermittent nudges. ## Install @@ -101,13 +100,16 @@ enabling the plugin. ### Hook state cleanup -Two per-session cache files live in `/tmp`: -- `/tmp/cix-aware-$SESSION_ID` — written by SessionStart, read by +Two per-session marker files live in `$CLAUDE_PLUGIN_DATA` +(resolves to `~/.claude/plugins/data/cix-code-index/`): +- `cix-aware-$SESSION_ID` — written by SessionStart, read by PreToolUse. Single-byte file (`0` or `1`). -- `/tmp/cix-grep-count-$SESSION_ID` — counter for the exponential - backoff. A few bytes. +- `cix-grep-count-$SESSION_ID` — counter for the exponential backoff. -`/tmp` is cleaned on reboot, so no manual cleanup is needed. +This directory is plugin-managed and **not** cleaned by the OS +(unlike `/tmp`, which macOS purges daily). SessionStart opportunistically +deletes its own markers older than 30 days on each invocation, so files +don't accumulate forever. ## Files diff --git a/plugins/cix/scripts/grep-nudge.sh b/plugins/cix/scripts/grep-nudge.sh index c1c8555..7cce165 100755 --- a/plugins/cix/scripts/grep-nudge.sh +++ b/plugins/cix/scripts/grep-nudge.sh @@ -1,64 +1,49 @@ #!/usr/bin/env bash # PreToolUse(Grep|Glob) hook for the cix plugin. # -# Behavior: when the model is about to invoke Grep or Glob in a project -# that the SessionStart hook flagged as cix-indexed, occasionally inject -# a system reminder pointing toward `cix search`. +# Behavior: if SessionStart concluded the project is cix-indexed +# ($CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID == "1"), occasionally +# inject a system reminder pointing toward `cix search` instead of +# Grep/Glob. Otherwise stay completely silent. +# +# This hook does NOT call `cix status` itself — it relies entirely on +# the cache written by SessionStart. Trade-off: a session that started +# before the cix-server came up will stay in "silent" mode for the rest +# of its life, even if the server is now reachable. That's intentional: +# we'd rather miss a few opportunities to nudge than spam a developer +# whose server is offline. # # Throttling: exponential backoff. Reminders fire on the 1st, 2nd, 4th, # 8th, 16th, 32nd, 64th, ... Grep/Glob invocation in the session. # A 100-Grep session sees ~7 reminders total (~560 bytes), loud at the -# start where the model is "learning" the workflow, fading to silence -# as the session wears on. -# -# Project detection: read /tmp/cix-aware-$SESSION_ID, written by -# session-start.sh after a successful `cix status` query. We do NOT -# call `cix status` ourselves here — this hook fires on every Grep/Glob -# call, so re-querying the server would be wasteful. Falls back to -# checking `.cixignore` if the cache file is missing (e.g. session -# resumed without re-running SessionStart). -# -# Output: nothing on non-power-of-2 invocations or in non-indexed -# projects. JSON with hookSpecificOutput.additionalContext on the 1st, -# 2nd, 4th, ... call. +# start, fading to silence as the session wears on. set -euo pipefail -# Read JSON input from stdin INPUT=$(cat 2>/dev/null || echo "{}") - if command -v jq >/dev/null 2>&1; then SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "") else SESSION_ID=$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) fi -# No session_id → can't dedupe nor read cache, stay silent. -if [ -z "$SESSION_ID" ]; then - exit 0 -fi - -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" +# No session_id → can't read the SessionStart cache. Stay silent. +[ -z "$SESSION_ID" ] && exit 0 -# ── Decide whether project is cix-aware ─────────────────────────────────────── -# Primary source: cache written by session-start.sh. -# Fallback: presence of .cixignore (less authoritative but doesn't require -# the SessionStart hook to have run, useful for resumed sessions). -CACHE_FILE="/tmp/cix-aware-$SESSION_ID" -IS_AWARE=0 +# ── Read SessionStart's verdict ─────────────────────────────────────────────── +# Strict policy: only "1" allows nudges. Missing file or "0" → silent. +CACHE_DIR="${CLAUDE_PLUGIN_DATA:-/tmp}" +CACHE_FILE="$CACHE_DIR/cix-aware-$SESSION_ID" -if [ -f "$CACHE_FILE" ]; then - [ "$(cat "$CACHE_FILE" 2>/dev/null)" = "1" ] && IS_AWARE=1 -elif [ -f "$PROJECT_DIR/.cixignore" ]; then - IS_AWARE=1 +if [ ! -f "$CACHE_FILE" ]; then + exit 0 fi - -if [ "$IS_AWARE" != "1" ]; then +if [ "$(cat "$CACHE_FILE" 2>/dev/null)" != "1" ]; then exit 0 fi # ── Increment per-session counter ───────────────────────────────────────────── -COUNTER_FILE="/tmp/cix-grep-count-$SESSION_ID" +COUNTER_FILE="$CACHE_DIR/cix-grep-count-$SESSION_ID" COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0) case "$COUNT" in ''|*[!0-9]*) COUNT=0 ;; diff --git a/plugins/cix/scripts/session-start.sh b/plugins/cix/scripts/session-start.sh index 11609b7..7e5b8d5 100755 --- a/plugins/cix/scripts/session-start.sh +++ b/plugins/cix/scripts/session-start.sh @@ -2,27 +2,30 @@ # SessionStart hook for the cix plugin. # # Behavior: at session start, ask `cix status` whether the current -# project is indexed. If yes, inject a one-line system reminder into -# Claude's context and cache the "is indexed" decision in -# /tmp/cix-aware-$SESSION_ID so the PreToolUse hook can short-circuit -# without re-querying the server. +# project is indexed. The result is cached for the session in +# $CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID so the PreToolUse hook can +# short-circuit without re-querying the server. # -# Why `cix status` and not `.cixignore`: -# - Authoritative: queries the cix-server, returns exit 0 only if the -# project is registered AND reachable at session start. -# - Fast: ~17ms on a local server (HTTP + SQLite lookup). -# - Robust: works for projects that don't keep a `.cixignore` file. +# State location: $CLAUDE_PLUGIN_DATA is plugin-persistent storage +# managed by Claude Code (resolves to ~/.claude/plugins/data//). +# It survives plugin updates and is NOT periodically cleaned by the OS, +# unlike /tmp (macOS daily cleanup of 3-day-old files; Linux on reboot). +# Falls back to /tmp only when run outside a plugin context (tests). # -# Output: nothing if the project isn't indexed (or cix isn't available); -# a JSON object with `hookSpecificOutput.additionalContext` otherwise. +# Decision contract (read by grep-nudge.sh): +# File present with content "1" → project is indexed, nudge allowed +# File present with content "0" → not indexed, nudge MUST stay silent +# File absent → SessionStart didn't run, nudge stays silent # -# Cache contract (read by grep-nudge.sh): -# /tmp/cix-aware-$SESSION_ID exists with content "1" → project is indexed -# File absent or content "0" → not indexed, stay silent +# Why no fallback in grep-nudge: if SessionStart concluded "not indexed" +# (server unreachable, project not registered, etc.), the user should +# NOT see Grep nudges suggesting `cix search` for the rest of the +# session. Sending nudges based on `.cixignore` presence anyway would +# create false positives. set -euo pipefail -# ── Read session_id from stdin JSON (Claude Code provides it) ───────────────── +# ── Read session_id from stdin JSON ─────────────────────────────────────────── INPUT=$(cat 2>/dev/null || echo "{}") if command -v jq >/dev/null 2>&1; then SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "") @@ -30,14 +33,25 @@ else SESSION_ID=$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) fi -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" -CACHE_FILE="" -if [ -n "$SESSION_ID" ]; then - CACHE_FILE="/tmp/cix-aware-$SESSION_ID" +# Without a session_id we can't write a session-scoped marker. Stay silent. +if [ -z "$SESSION_ID" ]; then + exit 0 fi +# ── Resolve cache directory ─────────────────────────────────────────────────── +# Prefer plugin-persistent storage; fall back to /tmp for ad-hoc/test invocations. +CACHE_DIR="${CLAUDE_PLUGIN_DATA:-/tmp}" +mkdir -p "$CACHE_DIR" 2>/dev/null || CACHE_DIR="/tmp" +CACHE_FILE="$CACHE_DIR/cix-aware-$SESSION_ID" + +# ── Light maintenance: clear markers older than 30 days ─────────────────────── +# Long-running Claude Code installs would accumulate one-byte markers +# otherwise. Cheap, runs once per session. Failures ignored. +find "$CACHE_DIR" -maxdepth 1 -type f \( -name 'cix-aware-*' -o -name 'cix-grep-count-*' \) -mtime +30 -delete 2>/dev/null || true + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" + # ── Resolve a working `cix` binary ──────────────────────────────────────────── -# Prefer the plugin-bundled wrapper, fall back to whatever is on PATH. CIX_BIN="" if [ -x "${CLAUDE_PLUGIN_ROOT:-}/bin/cix" ]; then CIX_BIN="${CLAUDE_PLUGIN_ROOT}/bin/cix" @@ -46,21 +60,20 @@ elif command -v cix >/dev/null 2>&1; then fi if [ -z "$CIX_BIN" ]; then - # No cix CLI yet (would be auto-installed on first call). Stay silent. - [ -n "$CACHE_FILE" ] && printf '0' > "$CACHE_FILE" + # CLI not yet installed (would auto-bootstrap on first call). Mark off. + printf '0' > "$CACHE_FILE" exit 0 fi -# ── Run `cix status` with a 2-second timeout (no coreutils dependency) ──────── -# We background the call, then wait up to 2s for it to finish. If it doesn't, -# kill it and treat as "not indexed". +# ── Run `cix status` with a 2-second timeout ────────────────────────────────── +# macOS lacks `timeout`/`gtimeout` by default — implement in pure bash. +EXIT_FILE="$CACHE_FILE.exit" ( "$CIX_BIN" status -p "$PROJECT_DIR" >/dev/null 2>&1 - echo "$?" > "${CACHE_FILE:-/dev/null}.exit" 2>/dev/null || true + echo "$?" > "$EXIT_FILE" 2>/dev/null ) & CIX_PID=$! -# Poll for completion up to 20 × 100ms = 2s SLEPT=0 while kill -0 "$CIX_PID" 2>/dev/null && [ "$SLEPT" -lt 20 ]; do sleep 0.1 @@ -68,30 +81,28 @@ while kill -0 "$CIX_PID" 2>/dev/null && [ "$SLEPT" -lt 20 ]; do done if kill -0 "$CIX_PID" 2>/dev/null; then - # Still running — kill it and treat as not indexed. kill -9 "$CIX_PID" 2>/dev/null || true wait "$CIX_PID" 2>/dev/null || true - [ -n "$CACHE_FILE" ] && printf '0' > "$CACHE_FILE" + printf '0' > "$CACHE_FILE" + rm -f "$EXIT_FILE" exit 0 fi - wait "$CIX_PID" 2>/dev/null || true -# Read the captured exit code (file written by the backgrounded subshell). EXIT_CODE=1 -if [ -n "$CACHE_FILE" ] && [ -f "${CACHE_FILE}.exit" ]; then - EXIT_CODE=$(cat "${CACHE_FILE}.exit" 2>/dev/null || echo 1) - rm -f "${CACHE_FILE}.exit" +if [ -f "$EXIT_FILE" ]; then + EXIT_CODE=$(cat "$EXIT_FILE" 2>/dev/null || echo 1) + rm -f "$EXIT_FILE" fi if [ "$EXIT_CODE" != "0" ]; then - # Not indexed (or cix-server unreachable). Stay silent. - [ -n "$CACHE_FILE" ] && printf '0' > "$CACHE_FILE" + # Not indexed (or server unreachable). Lock the session into "off" mode. + printf '0' > "$CACHE_FILE" exit 0 fi # ── Project IS indexed — cache + inject reminder ────────────────────────────── -[ -n "$CACHE_FILE" ] && printf '1' > "$CACHE_FILE" +printf '1' > "$CACHE_FILE" MESSAGE='💡 This project has a cix semantic code index. For semantic queries — finding code by meaning, cross-file lookups, symbol navigation, "where is X used", "how does Y work" — prefer `cix search`, `cix def`, `cix refs`, or the slash commands `/cix:search`, `/cix:def`, `/cix:refs`. Use Grep only for exact strings (error messages, config keys, import paths). Run `cix status` if results seem stale.' From f293caf5ca2c14efcc45ab14e2ea7ab992bfe6d4 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Fri, 8 May 2026 22:47:14 +0100 Subject: [PATCH 4/9] feat(plugin): add SessionEnd hook to clean per-session cache files Per review feedback: $CLAUDE_PLUGIN_DATA isn't cleaned by the OS, so we need to remove our own markers. Add a SessionEnd hook that deletes the two per-session files when a session terminates normally. scripts/session-end.sh: - Reads session_id from stdin JSON. - Removes $CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID and $CLAUDE_PLUGIN_DATA/cix-grep-count-$SESSION_ID. - Best-effort: failures silently ignored, since SessionEnd's last word shouldn't include error noise. hooks/hooks.json: - Register SessionEnd alongside the existing SessionStart and PreToolUse(Grep|Glob) entries. Two-tier cleanup strategy now in place: 1. SessionEnd handles the normal exit path. 2. 30-day GC in SessionStart covers forced kills (kill -9, OOM, panic) where SessionEnd never runs. End-to-end lifecycle test passed: SessionStart writes cache (1 byte), grep-nudge increments counter (NUDGE 1, 2, 4 / silent 3), SessionEnd removes both files leaving the cache dir empty. Docs updated (CLAUDE-CODE-PLUGIN.md, plugins/cix/README.md): explain the two-tier cleanup and document SessionEnd alongside the other hooks. Co-Authored-By: Claude Opus 4.7 --- CLAUDE-CODE-PLUGIN.md | 11 +++++++-- plugins/cix/README.md | 13 ++++++++--- plugins/cix/hooks/hooks.json | 10 +++++++++ plugins/cix/scripts/session-end.sh | 36 ++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100755 plugins/cix/scripts/session-end.sh diff --git a/CLAUDE-CODE-PLUGIN.md b/CLAUDE-CODE-PLUGIN.md index 8f614b4..ef701d0 100644 --- a/CLAUDE-CODE-PLUGIN.md +++ b/CLAUDE-CODE-PLUGIN.md @@ -43,6 +43,10 @@ project automatically gets: 100-Grep session. **Strict policy:** if the cache is missing or says `0`, the hook stays silent for the entire session. No `.cixignore` fallback, no inline `cix status` retries. + - **`SessionEnd`** — when the session terminates, deletes both + cache files (`cix-aware-*` and `cix-grep-count-*`) for that + session. Best-effort cleanup; survives forced kills via the 30-day + GC sweep that SessionStart performs. The strict cache contract means: a session that started while the cix-server was unreachable will stay in "silent" mode even if the @@ -53,8 +57,11 @@ Restart the Claude Code session to re-evaluate. State location: `$CLAUDE_PLUGIN_DATA` is plugin-persistent storage (resolves to `~/.claude/plugins/data/cix-code-index/`) — it survives plugin updates and is **not** cleaned by the OS, unlike `/tmp` (macOS -purges 3-day-old files daily; Linux clears on reboot). SessionStart -also opportunistically deletes its own markers older than 30 days. +purges 3-day-old files daily; Linux clears on reboot). Cleanup is +two-tiered: the SessionEnd hook removes per-session markers when a +session terminates normally, and SessionStart opportunistically deletes +markers older than 30 days as a safety net for sessions that exited +forcibly (kill -9, OOM, panic). --- diff --git a/plugins/cix/README.md b/plugins/cix/README.md index 5a6a355..684e602 100644 --- a/plugins/cix/README.md +++ b/plugins/cix/README.md @@ -28,6 +28,9 @@ Semantic code search and navigation for Claude Code, powered by the (fires on call #1, 2, 4, 8, 16, …). If the verdict is "no" (`0`) or missing, **stays silent for the entire session** — by design, so a flaky server doesn't cause intermittent nudges. + - **SessionEnd** — deletes the per-session cache files when the + session terminates. Best-effort cleanup; the 30-day GC inside + SessionStart catches markers left over from forced kills. ## Install @@ -107,9 +110,13 @@ Two per-session marker files live in `$CLAUDE_PLUGIN_DATA` - `cix-grep-count-$SESSION_ID` — counter for the exponential backoff. This directory is plugin-managed and **not** cleaned by the OS -(unlike `/tmp`, which macOS purges daily). SessionStart opportunistically -deletes its own markers older than 30 days on each invocation, so files -don't accumulate forever. +(unlike `/tmp`, which macOS purges daily). The plugin manages cleanup +in two tiers: +1. **SessionEnd hook** — deletes both markers when the session + terminates normally. Covers the common case. +2. **30-day GC in SessionStart** — opportunistically deletes markers + older than 30 days at every session start. Catches markers left + over from sessions that exited forcibly (kill -9, OOM). ## Files diff --git a/plugins/cix/hooks/hooks.json b/plugins/cix/hooks/hooks.json index b9474aa..8efcf82 100644 --- a/plugins/cix/hooks/hooks.json +++ b/plugins/cix/hooks/hooks.json @@ -20,6 +20,16 @@ } ] } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/session-end.sh" + } + ] + } ] } } diff --git a/plugins/cix/scripts/session-end.sh b/plugins/cix/scripts/session-end.sh new file mode 100755 index 0000000..d70d8dc --- /dev/null +++ b/plugins/cix/scripts/session-end.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# SessionEnd hook for the cix plugin. +# +# Behavior: when the Claude Code session terminates, remove this +# session's cache files from $CLAUDE_PLUGIN_DATA. Cleanup is best-effort: +# SessionEnd may not fire if the process was killed forcibly (kill -9, +# OOM, panic), which is why session-start.sh also runs a 30-day GC sweep. +# +# Files removed (per session_id): +# $CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID (verdict cache) +# $CLAUDE_PLUGIN_DATA/cix-grep-count-$SESSION_ID (backoff counter) +# +# Output: nothing. Failures are silently ignored — there's no useful +# action Claude could take if cleanup fails, and a noisy hook here would +# leak into the user's last view of the session. + +set -euo pipefail + +INPUT=$(cat 2>/dev/null || echo "{}") +if command -v jq >/dev/null 2>&1; then + SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "") +else + SESSION_ID=$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) +fi + +# Without a session_id we don't know what to clean. Exit cleanly. +[ -z "$SESSION_ID" ] && exit 0 + +CACHE_DIR="${CLAUDE_PLUGIN_DATA:-/tmp}" + +rm -f \ + "$CACHE_DIR/cix-aware-$SESSION_ID" \ + "$CACHE_DIR/cix-grep-count-$SESSION_ID" \ + 2>/dev/null || true + +exit 0 From 8f347e50bff267e6ced8e7d35d5317254bd58ccf Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Fri, 8 May 2026 23:07:06 +0100 Subject: [PATCH 5/9] feat(plugin): add CwdChanged + PostCompact hooks, per-(session, dir) cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new hooks address gaps that surfaced during design review: CwdChanged - When Claude changes working directory mid-session (`cd ../other`), the existing cache (`cix-aware-$SESSION_ID`) was stale — it held the verdict for the original project, not the new one. The hook now caches a separate verdict per (session, project_dir) so a multi-project flow gets correct cix-awareness in each. - Behavior: silent on cd (no reminder injection) — PreToolUse(Grep|Glob) handles the first-Grep-in-new-project nudge through its per-project backoff counter, which resets per directory. - No-op if the new directory was already evaluated in this session (Claude bouncing back to a known project). PostCompact - Skill bodies survive auto-compaction natively (re-attached up to 5K tokens per skill, see Claude Code skills docs). But the SessionStart `additionalContext` reminder — and PreToolUse nudges — are NOT skills; they're tool result messages and get dropped or summarised during compaction. - In long sessions (8+ hours) where the cix skill hasn't been invoked yet, the model can "forget" cix exists after compaction. PostCompact re-injects the SessionStart reminder if the current project's cache says "1". - No-op if cache is "0" or missing. Cache key change (breaking for in-flight sessions; benign for new ones) - Old: $CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID - New: $CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID-$DIR_HASH where DIR_HASH = `shasum -a 256 <<< $PROJECT_DIR | cut -c1-8` (works on both macOS and Linux; falls back to a path-derived suffix if shasum is missing). - Per-project state means each directory has its own backoff counter: a fresh `cd` into a cix-aware project always fires a nudge on the first Grep, not the 32nd. session-start.sh, grep-nudge.sh: refactored to compute DIR_HASH inline and use the new key. Same algorithm in both; bash sourcing avoided to keep each script standalone-runnable for testing. session-end.sh: switched from `rm -f $cix-aware-$SID $cix-grep-count-$SID` to a glob `find -name 'cix-aware-$SID-*' -delete` so it cleans every per-(session, dir) marker the session created. hooks.json: register CwdChanged and PostCompact alongside SessionStart, PreToolUse(Grep|Glob), and SessionEnd. Five hooks total now. doc/TODO.md (new): document `PostToolUseFailure` for `Bash(cix *)` as a v0.2 deferred item. Original ask was an interactive UI prompt ("Disable cix plugin for this session? Yes/No"); investigation showed Claude Code's hook API doesn't support arbitrary user dialogs (`permissionDecision: "ask"` is PreToolUse-only). The functional equivalent — overwrite cache to "0" + inject explanation message — is straightforward but the underlying problem (cix-server flapping mid-session) is rare enough that we want real-usage data before shipping. ~1 day of work, queued. Test coverage: 10-step multi-dir integration test exercises the full lifecycle — SessionStart, Greps with backoff, CwdChanged into non-cix dir, silent Greps there, CwdChanged back (preserves counter), more Greps continuing the original backoff, PostCompact re-injection, PostCompact silent for non-cix dir, 3 cache files persisted, SessionEnd glob cleans everything. Docs (CLAUDE-CODE-PLUGIN.md, plugins/cix/README.md): "Behavioral hooks" sections enumerate all 5 hooks, explain the per-(session, dir) cache key, and document the strict cache contract for each. Co-Authored-By: Claude Opus 4.7 --- CLAUDE-CODE-PLUGIN.md | 73 +++++++++++------- doc/TODO.md | 109 +++++++++++++++++++++++++++ plugins/cix/README.md | 34 ++++++--- plugins/cix/hooks/hooks.json | 20 +++++ plugins/cix/scripts/cwd-changed.sh | 101 +++++++++++++++++++++++++ plugins/cix/scripts/grep-nudge.sh | 47 +++++++----- plugins/cix/scripts/post-compact.sh | 62 +++++++++++++++ plugins/cix/scripts/session-end.sh | 28 +++---- plugins/cix/scripts/session-start.sh | 40 ++++++---- 9 files changed, 431 insertions(+), 83 deletions(-) create mode 100644 doc/TODO.md create mode 100755 plugins/cix/scripts/cwd-changed.sh create mode 100755 plugins/cix/scripts/post-compact.sh diff --git a/CLAUDE-CODE-PLUGIN.md b/CLAUDE-CODE-PLUGIN.md index ef701d0..00e7a7d 100644 --- a/CLAUDE-CODE-PLUGIN.md +++ b/CLAUDE-CODE-PLUGIN.md @@ -29,39 +29,54 @@ project automatically gets: cix vs Grep, query patterns, scoring landscape, CLI flags). **Lazy-loaded** by Claude Code — enters context only when invoked, stays once per session. -- **Behavioral hooks:** - - **`SessionStart`** — at session start, runs `cix status` (with a - 2-second timeout) to ask the cix-server whether the current - project is registered. The result (`1` or `0`) is cached in - `$CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID` for the rest of the - session. On `1`, injects a one-line reminder telling Claude that - semantic search is available; on `0`, stays silent. - - **`PreToolUse(Grep|Glob)`** — reads the SessionStart cache (~1 ms, - no cix call). If the cache says `1`, occasionally suggests - `cix search` instead of Grep, throttled with **exponential backoff** - (fires on call #1, 2, 4, 8, 16, 32, 64, …) — at most ~7 nudges per - 100-Grep session. **Strict policy:** if the cache is missing or - says `0`, the hook stays silent for the entire session. No - `.cixignore` fallback, no inline `cix status` retries. - - **`SessionEnd`** — when the session terminates, deletes both - cache files (`cix-aware-*` and `cix-grep-count-*`) for that - session. Best-effort cleanup; survives forced kills via the 30-day - GC sweep that SessionStart performs. +- **Behavioral hooks (5 total):** + - **`SessionStart`** — at session start, runs `cix status` (2-second + timeout) to ask the cix-server whether the current project is + registered. The verdict (`1` or `0`) is cached in + `$CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID-$DIR_HASH`. On `1`, + injects a one-line reminder. + - **`CwdChanged`** — when Claude changes working directory mid-session + (e.g. via `cd ../other-project`), evaluates cix-awareness for the + NEW directory and caches the verdict. Silent (no reminder) — the + next Grep/Glob call will fire the standard backoff nudge if + appropriate. No-op if the new directory was already evaluated in + this session (Claude bouncing back to a known project). + - **`PreToolUse(Grep|Glob)`** — reads the cache for the current + `(session, project_dir)` pair (~1 ms, no cix call). If the cache + says `1`, occasionally suggests `cix search` instead of Grep, + throttled with **exponential backoff** (fires on call #1, 2, 4, 8, + 16, 32, 64, … *per project*). Each project visited in a session + starts a fresh backoff counter, so the first Grep in a new + cix-aware project always gets a nudge. **Strict policy:** missing + cache or `0` → silent for the entire session in that project. + - **`PostCompact`** — after Claude Code compacts the conversation + (long-running sessions), re-injects the SessionStart reminder if + the current project is cix-aware. Skill bodies survive compaction + natively, but the SessionStart `additionalContext` does not — this + hook keeps cix-awareness alive after compaction without relying on + the skill being invoked yet. + - **`SessionEnd`** — when the session terminates, deletes every + cache file belonging to this session (glob: all `(session, *)` + pairs across every project visited). + +**Cache key includes a project-dir hash** (`shasum -a 256` first 8 +chars), so a single Claude Code session that traverses multiple +projects keeps a separate verdict per project — fresh backoff per +project, correct cix-aware state per directory. The strict cache contract means: a session that started while the -cix-server was unreachable will stay in "silent" mode even if the -server comes back online. That's intentional — we'd rather miss a -few nudge opportunities than pester a developer whose server is down. -Restart the Claude Code session to re-evaluate. +cix-server was unreachable will stay in "silent" mode for that project +even if the server comes back online. Restart the Claude Code session +or `cd` away and back to re-evaluate. Better to miss a few nudges than +to pester a developer whose server is down. State location: `$CLAUDE_PLUGIN_DATA` is plugin-persistent storage -(resolves to `~/.claude/plugins/data/cix-code-index/`) — it survives -plugin updates and is **not** cleaned by the OS, unlike `/tmp` (macOS -purges 3-day-old files daily; Linux clears on reboot). Cleanup is -two-tiered: the SessionEnd hook removes per-session markers when a -session terminates normally, and SessionStart opportunistically deletes -markers older than 30 days as a safety net for sessions that exited -forcibly (kill -9, OOM, panic). +(`~/.claude/plugins/data/cix-code-index/`) — it survives plugin updates +and is **not** cleaned by the OS, unlike `/tmp` (macOS purges 3-day-old +files daily; Linux clears on reboot). Cleanup is two-tiered: SessionEnd +removes per-session markers on normal exit; SessionStart opportunistically +deletes markers older than 30 days as a safety net for forced kills +(kill -9, OOM, panic). --- diff --git a/doc/TODO.md b/doc/TODO.md new file mode 100644 index 0000000..01782ef --- /dev/null +++ b/doc/TODO.md @@ -0,0 +1,109 @@ +# TODO / Roadmap + +Tracked deferred work for the cix project. Items here are deliberately +postponed — typically because they require data from real-world usage, +need a separate design pass, or sit outside the current release scope. + +When you start an item, link the PR/branch in the relevant section. + +--- + +## Plugin v0.2 + +### `PostToolUseFailure` hook for `Bash(cix *)` — graceful degradation when cix-server is unreachable + +**Status:** designed, not implemented. + +**Problem.** During a Claude Code session the cix-server can become +unreachable mid-flight (Docker restart, OOM, OS sleep, network blip). +The plugin's `SessionStart` hook ran successfully at session start and +cached `cix-aware = "1"` for the project. PreToolUse(Grep|Glob) keeps +nudging the model to use `cix search`. The model dutifully runs `cix +search …`, the CLI exits non-zero, and the model sees an error. Plugin +keeps nudging on the next Grep — model retries cix — fails again. Loop. + +**Why we're deferring.** The edge case is rare on a stable local server, +the loop is annoying but not destructive (model picks up after a couple +of failures and switches back to Grep), and the manual workaround +(restart Claude Code session, or set the cache file to `0`) is trivial. +We'd rather collect data on real failure rates from v0.1 before adding +more state machinery. + +**The interactive-prompt question.** Initial intent was an actual UI +dialog: "cix-server unreachable. Disable cix nudges for this session? +[Yes] [No]". After investigating Claude Code's hook API, this isn't +available — `permissionDecision: "ask"` only works for `PreToolUse` +events, and `PostToolUseFailure` does not accept it. There's no +mechanism for a hook to trigger an arbitrary user prompt. + +**Functional equivalent that IS available:** `PostToolUseFailure` can +return `hookSpecificOutput.additionalContext`. We can use that to +1. Overwrite `$CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID-$DIR_HASH` + with `"0"` — silencing all subsequent PreToolUse(Grep|Glob) and + PostCompact hooks for the rest of the session in this project. +2. Inject a one-line message via `additionalContext`: + > 💡 cix command failed (server unreachable). Disabled cix nudges + > for this session. Run `cix status` and restart Claude Code if + > you've fixed the server. + +The model relays this to the user in its next response. Effect is +identical to "user clicked Yes on a Disable dialog": plugin goes silent, +user is informed and decides what to do. No actual interactive UI, but +the developer experience is the same. + +**Implementation sketch:** + +1. New script `plugins/cix/scripts/cix-failed.sh` — reads `session_id`, + computes `DIR_HASH`, overwrites the cache file with `"0"`, emits + the JSON message. +2. Register it in `plugins/cix/hooks/hooks.json`: + ```json + "PostToolUseFailure": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/cix-failed.sh" + } + ] + } + ] + ``` +3. Inside the script, parse `tool_input.command` from stdin and exit + silently if it doesn't start with `cix ` — so unrelated Bash + failures don't trigger the disable path. +4. Idempotent: if cache is already `"0"`, no-op (avoid re-injecting the + message on every subsequent failure). + +**Ship criteria.** Wait for at least one user report (or one self-observed +incident) where v0.1 plugin loops on cix failures before implementing. +Otherwise we're solving a phantom problem. + +**Estimate:** ~1 day. ~50 lines of bash, hooks.json registration, doc +updates in `CLAUDE-CODE-PLUGIN.md`, manual test scenario covering +`docker compose stop` mid-session. + +--- + +### Other deferred plugin work + +- **MCP server** exposing `cix_search` / `cix_definitions` / `cix_references` + as native Claude tools, so cix becomes available in pure Claude + Desktop chat (where plugins don't run). +- **`PreToolUse(Bash)` matcher** that catches inline `grep` calls + (`Bash(grep ...)`) — currently the plugin only nudges on the dedicated + `Grep`/`Glob` tools, not on `grep` invoked through `Bash`. +- **`cix-explorer` subagent** preconfigured for codebase exploration — + `Skill: cix` preloaded + `context: fork` + `agent: Explore` + read-only + tool whitelist. +- **Plugin tag stream + `release-plugin.yml` workflow** so the plugin + has its own version tags (`plugin/v0.1.0`, `plugin/v0.2.0`, …) + alongside `cli/v*` and `server/v*`. + +--- + +## Server / CLI + +(none currently tracked here; server and CLI roadmap is in their +respective changelogs and release-server.yml / release-cli.yml workflows) diff --git a/plugins/cix/README.md b/plugins/cix/README.md index 684e602..052cf02 100644 --- a/plugins/cix/README.md +++ b/plugins/cix/README.md @@ -18,20 +18,32 @@ Semantic code search and navigation for Claude Code, powered by the invoke it (`/cix:search`, `/cix-skill`, or auto-trigger on a relevant prompt). Stays in context for the rest of the session — never duplicated. -- **Behavioral nudges (hooks):** +- **Behavioral nudges (5 hooks):** - **SessionStart** — calls `cix status` (2 s timeout). Caches the - yes/no verdict in `$CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID`, - injects a one-line reminder on success, stays silent on failure. - - **PreToolUse(Grep|Glob)** — reads the SessionStart cache only; no - inline `cix` calls. If the verdict is "yes" (`1`), suggests - `cix search` instead of Grep, throttled with exponential backoff - (fires on call #1, 2, 4, 8, 16, …). If the verdict is "no" (`0`) - or missing, **stays silent for the entire session** — by design, - so a flaky server doesn't cause intermittent nudges. - - **SessionEnd** — deletes the per-session cache files when the - session terminates. Best-effort cleanup; the 30-day GC inside + yes/no verdict in `$CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID-$DIR_HASH`, + injects a one-line reminder on success. + - **CwdChanged** — when Claude `cd`s into another directory mid-session, + re-runs `cix status` for the new dir and caches the verdict. Silent + (no reminder); PreToolUse handles the first-Grep-in-new-project + nudge through its per-project backoff. + - **PreToolUse(Grep|Glob)** — reads the cache for the current + `(session, project_dir)` pair; no inline `cix` calls. If the + verdict is "yes" (`1`), suggests `cix search` with exponential + backoff per project (fires on call #1, 2, 4, 8, …). Missing cache + or "no" (`0`) → silent for the rest of the session in that project. + - **PostCompact** — after auto-compaction in long sessions, re-injects + the SessionStart reminder if the current project is cix-aware + (skill body itself survives compaction natively; the SessionStart + one-liner does not). + - **SessionEnd** — glob-deletes every per-(session, dir) cache file + when the session terminates. Best-effort; the 30-day GC inside SessionStart catches markers left over from forced kills. +The cache key includes a project-dir hash (`shasum -a 256` first 8 +chars), so per-session, per-project state is isolated — Claude can +move between projects mid-session and each one keeps its own verdict +and backoff counter. + ## Install From an existing Claude Code marketplace: diff --git a/plugins/cix/hooks/hooks.json b/plugins/cix/hooks/hooks.json index 8efcf82..81435cd 100644 --- a/plugins/cix/hooks/hooks.json +++ b/plugins/cix/hooks/hooks.json @@ -21,6 +21,26 @@ ] } ], + "CwdChanged": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/cwd-changed.sh" + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/post-compact.sh" + } + ] + } + ], "SessionEnd": [ { "hooks": [ diff --git a/plugins/cix/scripts/cwd-changed.sh b/plugins/cix/scripts/cwd-changed.sh new file mode 100755 index 0000000..6c49f8f --- /dev/null +++ b/plugins/cix/scripts/cwd-changed.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# CwdChanged hook for the cix plugin. +# +# Behavior: when Claude changes working directory mid-session (e.g. via +# `cd`), evaluate cix-awareness for the new directory and cache the +# verdict. If we already have a verdict for this (session, project_dir) +# pair, this is a no-op — Claude probably came back to a project we +# already evaluated. +# +# Why no reminder injection: PreToolUse(Grep|Glob) handles the +# "first nudge in a fresh project" case via its per-project backoff +# counter (call #1 in a new project always fires). Re-inject a SessionStart +# reminder on every `cd` would be noisy if Claude bounces between +# directories. +# +# Behavior matrix: +# Cache exists for (session, NEW_DIR) → no-op (we know already) +# Cache absent + cix status exit 0 → write "1" (cix-aware) +# Cache absent + cix status exit ≠ 0 → write "0" (silent for this dir) +# Cache absent + cix CLI not found → write "0" +# Cache absent + cix status timeout → write "0" + +set -euo pipefail + +INPUT=$(cat 2>/dev/null || echo "{}") +if command -v jq >/dev/null 2>&1; then + SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "") +else + SESSION_ID=$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) +fi + +[ -z "$SESSION_ID" ] && exit 0 + +CACHE_DIR="${CLAUDE_PLUGIN_DATA:-/tmp}" +mkdir -p "$CACHE_DIR" 2>/dev/null || CACHE_DIR="/tmp" + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +DIR_HASH=$(printf '%s' "$PROJECT_DIR" | shasum -a 256 2>/dev/null | cut -c1-8) +if [ -z "$DIR_HASH" ]; then + DIR_HASH=$(printf '%s' "$PROJECT_DIR" | tr -c 'a-zA-Z0-9' '-' | tail -c 16) +fi + +CACHE_FILE="$CACHE_DIR/cix-aware-$SESSION_ID-$DIR_HASH" + +# ── Already evaluated this (session, project) — no-op ───────────────────────── +if [ -f "$CACHE_FILE" ]; then + exit 0 +fi + +# ── Resolve cix binary ──────────────────────────────────────────────────────── +CIX_BIN="" +if [ -x "${CLAUDE_PLUGIN_ROOT:-}/bin/cix" ]; then + CIX_BIN="${CLAUDE_PLUGIN_ROOT}/bin/cix" +elif command -v cix >/dev/null 2>&1; then + CIX_BIN="$(command -v cix)" +fi + +if [ -z "$CIX_BIN" ]; then + printf '0' > "$CACHE_FILE" + exit 0 +fi + +# ── Run cix status with 2s timeout (same pattern as session-start.sh) ───────── +EXIT_FILE="$CACHE_FILE.exit" +( + "$CIX_BIN" status -p "$PROJECT_DIR" >/dev/null 2>&1 + echo "$?" > "$EXIT_FILE" 2>/dev/null +) & +CIX_PID=$! + +SLEPT=0 +while kill -0 "$CIX_PID" 2>/dev/null && [ "$SLEPT" -lt 20 ]; do + sleep 0.1 + SLEPT=$((SLEPT + 1)) +done + +if kill -0 "$CIX_PID" 2>/dev/null; then + kill -9 "$CIX_PID" 2>/dev/null || true + wait "$CIX_PID" 2>/dev/null || true + printf '0' > "$CACHE_FILE" + rm -f "$EXIT_FILE" + exit 0 +fi +wait "$CIX_PID" 2>/dev/null || true + +EXIT_CODE=1 +if [ -f "$EXIT_FILE" ]; then + EXIT_CODE=$(cat "$EXIT_FILE" 2>/dev/null || echo 1) + rm -f "$EXIT_FILE" +fi + +if [ "$EXIT_CODE" = "0" ]; then + printf '1' > "$CACHE_FILE" +else + printf '0' > "$CACHE_FILE" +fi + +# Silent — no context injection. PreToolUse(Grep|Glob) will handle the +# first-Grep-in-new-project nudge through its own backoff counter. +exit 0 diff --git a/plugins/cix/scripts/grep-nudge.sh b/plugins/cix/scripts/grep-nudge.sh index 7cce165..73fa12b 100755 --- a/plugins/cix/scripts/grep-nudge.sh +++ b/plugins/cix/scripts/grep-nudge.sh @@ -1,22 +1,27 @@ #!/usr/bin/env bash # PreToolUse(Grep|Glob) hook for the cix plugin. # -# Behavior: if SessionStart concluded the project is cix-indexed -# ($CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID == "1"), occasionally -# inject a system reminder pointing toward `cix search` instead of -# Grep/Glob. Otherwise stay completely silent. +# Behavior: if SessionStart (or CwdChanged) concluded the current +# project is cix-indexed (cache file for this (session, project_dir) +# pair contains "1"), occasionally inject a system reminder pointing +# toward `cix search` instead of Grep/Glob. Otherwise stay silent. # # This hook does NOT call `cix status` itself — it relies entirely on -# the cache written by SessionStart. Trade-off: a session that started -# before the cix-server came up will stay in "silent" mode for the rest -# of its life, even if the server is now reachable. That's intentional: -# we'd rather miss a few opportunities to nudge than spam a developer -# whose server is offline. +# the cache written by SessionStart and refreshed by CwdChanged. +# Trade-off: a session that started before the cix-server came up will +# stay in "silent" mode for the rest of its life in that project, even +# if the server later comes back online. Intentional: better to miss a +# few nudge opportunities than spam a developer whose server is down. +# +# Per-(session, project) backoff: each project Claude visits has its +# own exponential-backoff counter. A new `cd` into a fresh project +# starts the backoff from scratch (call #1 → nudge), so the first Grep +# in a new cix-aware project always gets a reminder. # # Throttling: exponential backoff. Reminders fire on the 1st, 2nd, 4th, -# 8th, 16th, 32nd, 64th, ... Grep/Glob invocation in the session. -# A 100-Grep session sees ~7 reminders total (~560 bytes), loud at the -# start, fading to silence as the session wears on. +# 8th, 16th, 32nd, 64th, ... Grep/Glob invocation in the current +# project. ~7 reminders per 100-Grep span, loud at the start, fading +# as the model "learns" the workflow. set -euo pipefail @@ -30,10 +35,18 @@ fi # No session_id → can't read the SessionStart cache. Stay silent. [ -z "$SESSION_ID" ] && exit 0 -# ── Read SessionStart's verdict ─────────────────────────────────────────────── -# Strict policy: only "1" allows nudges. Missing file or "0" → silent. CACHE_DIR="${CLAUDE_PLUGIN_DATA:-/tmp}" -CACHE_FILE="$CACHE_DIR/cix-aware-$SESSION_ID" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +# Compute per-project hash — same algorithm as session-start.sh. +DIR_HASH=$(printf '%s' "$PROJECT_DIR" | shasum -a 256 2>/dev/null | cut -c1-8) +if [ -z "$DIR_HASH" ]; then + DIR_HASH=$(printf '%s' "$PROJECT_DIR" | tr -c 'a-zA-Z0-9' '-' | tail -c 16) +fi + +# ── Read SessionStart's verdict for THIS project ────────────────────────────── +# Strict policy: only "1" allows nudges. Missing file or "0" → silent. +CACHE_FILE="$CACHE_DIR/cix-aware-$SESSION_ID-$DIR_HASH" if [ ! -f "$CACHE_FILE" ]; then exit 0 @@ -42,8 +55,8 @@ if [ "$(cat "$CACHE_FILE" 2>/dev/null)" != "1" ]; then exit 0 fi -# ── Increment per-session counter ───────────────────────────────────────────── -COUNTER_FILE="$CACHE_DIR/cix-grep-count-$SESSION_ID" +# ── Increment per-(session, project) counter ────────────────────────────────── +COUNTER_FILE="$CACHE_DIR/cix-grep-count-$SESSION_ID-$DIR_HASH" COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0) case "$COUNT" in ''|*[!0-9]*) COUNT=0 ;; diff --git a/plugins/cix/scripts/post-compact.sh b/plugins/cix/scripts/post-compact.sh new file mode 100755 index 0000000..b7d982b --- /dev/null +++ b/plugins/cix/scripts/post-compact.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# PostCompact hook for the cix plugin. +# +# Behavior: after Claude Code compacts the conversation, re-inject the +# SessionStart reminder if this (session, project) is cix-aware. +# +# Why this matters: skill bodies survive auto-compaction (Claude Code +# re-attaches them with up to 5K tokens per skill, see +# https://code.claude.com/docs/en/skills#skill-content-lifecycle). +# But the SessionStart `additionalContext` reminder — and PreToolUse +# nudges — are NOT skills. They live as regular tool result messages +# and are dropped/summarised during compaction. +# +# In long sessions (8+ hours of work) where the cix skill hasn't been +# invoked yet, the model may "forget" cix exists after compaction. +# Re-injecting the same one-line reminder keeps cix-awareness alive. +# +# This is a no-op if SessionStart concluded the project is not indexed +# (cache=0) or if no verdict exists yet. + +set -euo pipefail + +INPUT=$(cat 2>/dev/null || echo "{}") +if command -v jq >/dev/null 2>&1; then + SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "") +else + SESSION_ID=$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) +fi + +[ -z "$SESSION_ID" ] && exit 0 + +CACHE_DIR="${CLAUDE_PLUGIN_DATA:-/tmp}" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +DIR_HASH=$(printf '%s' "$PROJECT_DIR" | shasum -a 256 2>/dev/null | cut -c1-8) +if [ -z "$DIR_HASH" ]; then + DIR_HASH=$(printf '%s' "$PROJECT_DIR" | tr -c 'a-zA-Z0-9' '-' | tail -c 16) +fi + +CACHE_FILE="$CACHE_DIR/cix-aware-$SESSION_ID-$DIR_HASH" + +# ── Read verdict ────────────────────────────────────────────────────────────── +# Strict policy mirrors grep-nudge.sh: only "1" triggers re-injection. +if [ ! -f "$CACHE_FILE" ]; then + exit 0 +fi +if [ "$(cat "$CACHE_FILE" 2>/dev/null)" != "1" ]; then + exit 0 +fi + +# ── Re-inject the SessionStart reminder ─────────────────────────────────────── +MESSAGE='💡 (Post-compact reminder) This project has a cix semantic code index. For semantic queries — finding code by meaning, cross-file lookups, symbol navigation, "where is X used", "how does Y work" — prefer `cix search`, `cix def`, `cix refs`, or the slash commands `/cix:search`, `/cix:def`, `/cix:refs`. Use Grep only for exact strings (error messages, config keys, import paths).' + +if command -v jq >/dev/null 2>&1; then + jq -n --arg msg "$MESSAGE" \ + '{hookSpecificOutput: {hookEventName: "PostCompact", additionalContext: $msg}}' +else + ESC=$(printf '%s' "$MESSAGE" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ') + printf '{"hookSpecificOutput":{"hookEventName":"PostCompact","additionalContext":"%s"}}\n' "$ESC" +fi + +exit 0 diff --git a/plugins/cix/scripts/session-end.sh b/plugins/cix/scripts/session-end.sh index d70d8dc..7b7efe8 100755 --- a/plugins/cix/scripts/session-end.sh +++ b/plugins/cix/scripts/session-end.sh @@ -1,18 +1,19 @@ #!/usr/bin/env bash # SessionEnd hook for the cix plugin. # -# Behavior: when the Claude Code session terminates, remove this -# session's cache files from $CLAUDE_PLUGIN_DATA. Cleanup is best-effort: +# Behavior: when the Claude Code session terminates, remove every +# cache file belonging to this session from $CLAUDE_PLUGIN_DATA. +# A single session may have visited multiple projects (via `cd`), so +# we glob-delete by session_id prefix. Cleanup is best-effort: # SessionEnd may not fire if the process was killed forcibly (kill -9, -# OOM, panic), which is why session-start.sh also runs a 30-day GC sweep. +# OOM, panic) — session-start.sh also runs a 30-day GC sweep as a +# safety net. # -# Files removed (per session_id): -# $CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID (verdict cache) -# $CLAUDE_PLUGIN_DATA/cix-grep-count-$SESSION_ID (backoff counter) +# Files removed (per session_id, all directory hashes): +# $CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID-* (verdict caches) +# $CLAUDE_PLUGIN_DATA/cix-grep-count-$SESSION_ID-* (backoff counters) # -# Output: nothing. Failures are silently ignored — there's no useful -# action Claude could take if cleanup fails, and a noisy hook here would -# leak into the user's last view of the session. +# Output: nothing. Failures are silently ignored. set -euo pipefail @@ -28,9 +29,10 @@ fi CACHE_DIR="${CLAUDE_PLUGIN_DATA:-/tmp}" -rm -f \ - "$CACHE_DIR/cix-aware-$SESSION_ID" \ - "$CACHE_DIR/cix-grep-count-$SESSION_ID" \ - 2>/dev/null || true +# Glob-delete every per-(session, dir) marker. Use find for safe handling +# of patterns when there are no matches (avoids "rm: ... no such file" noise). +find "$CACHE_DIR" -maxdepth 1 -type f \ + \( -name "cix-aware-$SESSION_ID-*" -o -name "cix-grep-count-$SESSION_ID-*" \) \ + -delete 2>/dev/null || true exit 0 diff --git a/plugins/cix/scripts/session-start.sh b/plugins/cix/scripts/session-start.sh index 7e5b8d5..9a4b433 100755 --- a/plugins/cix/scripts/session-start.sh +++ b/plugins/cix/scripts/session-start.sh @@ -2,9 +2,14 @@ # SessionStart hook for the cix plugin. # # Behavior: at session start, ask `cix status` whether the current -# project is indexed. The result is cached for the session in -# $CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID so the PreToolUse hook can -# short-circuit without re-querying the server. +# project is indexed. The result is cached for the (session, project) +# pair in $CLAUDE_PLUGIN_DATA/cix-aware-$SESSION_ID-$DIR_HASH so the +# PreToolUse hook can short-circuit without re-querying the server. +# +# Cache key includes a hash of the project directory, so a single +# session that traverses multiple projects (via `cd`, see CwdChanged +# hook) keeps a separate verdict per project — fresh backoff counter +# per project, correct cix-aware state per directory. # # State location: $CLAUDE_PLUGIN_DATA is plugin-persistent storage # managed by Claude Code (resolves to ~/.claude/plugins/data//). @@ -12,16 +17,16 @@ # unlike /tmp (macOS daily cleanup of 3-day-old files; Linux on reboot). # Falls back to /tmp only when run outside a plugin context (tests). # -# Decision contract (read by grep-nudge.sh): +# Decision contract (read by grep-nudge.sh, post-compact.sh): # File present with content "1" → project is indexed, nudge allowed # File present with content "0" → not indexed, nudge MUST stay silent -# File absent → SessionStart didn't run, nudge stays silent +# File absent → no verdict yet, nudge stays silent # -# Why no fallback in grep-nudge: if SessionStart concluded "not indexed" -# (server unreachable, project not registered, etc.), the user should -# NOT see Grep nudges suggesting `cix search` for the rest of the -# session. Sending nudges based on `.cixignore` presence anyway would -# create false positives. +# Why no fallback in grep-nudge: if SessionStart (or CwdChanged) concluded +# "not indexed" (server unreachable, project not registered, etc.), the +# user should NOT see Grep nudges suggesting `cix search` for the rest +# of the session. Sending nudges based on `.cixignore` presence anyway +# would create false positives. set -euo pipefail @@ -42,15 +47,24 @@ fi # Prefer plugin-persistent storage; fall back to /tmp for ad-hoc/test invocations. CACHE_DIR="${CLAUDE_PLUGIN_DATA:-/tmp}" mkdir -p "$CACHE_DIR" 2>/dev/null || CACHE_DIR="/tmp" -CACHE_FILE="$CACHE_DIR/cix-aware-$SESSION_ID" + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +# Hash the project dir so the cache file name is short and stable. +# `shasum -a 256` exists on both macOS (Perl-based) and Linux (coreutils). +DIR_HASH=$(printf '%s' "$PROJECT_DIR" | shasum -a 256 2>/dev/null | cut -c1-8) +if [ -z "$DIR_HASH" ]; then + # shasum unavailable; fall back to a path-derived suffix. + DIR_HASH=$(printf '%s' "$PROJECT_DIR" | tr -c 'a-zA-Z0-9' '-' | tail -c 16) +fi + +CACHE_FILE="$CACHE_DIR/cix-aware-$SESSION_ID-$DIR_HASH" # ── Light maintenance: clear markers older than 30 days ─────────────────────── # Long-running Claude Code installs would accumulate one-byte markers # otherwise. Cheap, runs once per session. Failures ignored. find "$CACHE_DIR" -maxdepth 1 -type f \( -name 'cix-aware-*' -o -name 'cix-grep-count-*' \) -mtime +30 -delete 2>/dev/null || true -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" - # ── Resolve a working `cix` binary ──────────────────────────────────────────── CIX_BIN="" if [ -x "${CLAUDE_PLUGIN_ROOT:-}/bin/cix" ]; then From 7f705471c08b5bfd4450f6fa013b04a4609bf654 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Fri, 8 May 2026 23:19:38 +0100 Subject: [PATCH 6/9] test(plugin): add bats-core test suite + GitHub Actions CI + path guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address security concern: bash hook scripts run on every Claude Code session and call `find -delete` against $CLAUDE_PLUGIN_DATA. Without tests and without runtime guards, a misconfigured environment could in principle wipe arbitrary files. Layered defense: 1. Path validation guards (runtime, in scripts) session-start.sh and session-end.sh now refuse to operate if $CLAUDE_PLUGIN_DATA is outside the whitelist: - $HOME/.claude/plugins/data/* (official plugin-data dir) - /tmp or /tmp/* (fallback / installer scratch) - $TMPDIR/* (macOS test sandboxes) Anything else (e.g. /, $HOME, /etc) prints a refusal message and exits non-zero without touching anything. 2. Conservative find patterns (already in place, hardened) Every find -delete uses -maxdepth 1, -type f, and a tight -name filter. No rm -rf anywhere in the plugin. Subdirectories, symlinks, and files outside the cix-aware-* / cix-grep-count-* patterns are never reached. 3. bats-core test suite (plugins/cix/tests/) 46 tests across 6 files, all passing on macOS. Covers: - session-start.bats — cix-status flow, cache write, GC, guard refusals (=, =$HOME, =/etc), guard accepts (plugin-data, /tmp, $TMPDIR) - cwd-changed.bats — first-cd evaluation, no-op on cached dir, multi-dir cache, silent on cd, timeout - grep-nudge.bats — exponential backoff (1, 2, 4, 8, 16), per-(session, dir) counters, no cix CLI calls (cache-only) - post-compact.bats — re-injection only when cache="1" - session-end.bats — glob deletion across dir hashes, SECURITY: never touches other sessions' files, never touches non-cix files even if they look similar, GUARD refuses on / and $HOME and /etc, SECURITY: shell-injection in session_id does not delete a /tmp canary - cix-wrapper.bats — system-cix passthrough, exit code propagation, arg propagation, self-recursion guard via symlink chain Mocks: tests/mocks/bin/cix is a fake CLI controlled via env vars (MOCK_CIX_EXIT, MOCK_CIX_DELAY, MOCK_CIX_LOG_FILE). helpers.bash provides setup_test_env / teardown_test_env / run_hook / make_cache / read_cache / read_counter / compute_hash / mock_cix_call_count. 4. GitHub Actions workflow (.github/workflows/ci-plugin.yml) Runs on push and PR when plugins/cix/** or .claude-plugin/** changes. Matrix: ubuntu-latest + macos-latest. Steps: - Install bats-core, jq, shellcheck (apt or brew) - Run bats --tap plugins/cix/tests/*.bats - Run shellcheck --severity=warning on all hook scripts - Validate JSON manifests (marketplace, plugin, hooks) - Verify bin/cix symlink integrity Fails on shellcheck warnings, not just errors — keeps scripts defensive about quoting, word splitting, and unsafe globs. 5. Documentation plugins/cix/tests/README.md — how to run locally, test matrix summary, mock interface, how to add new tests. CLAUDE-CODE-PLUGIN.md — new "Security & testing" section documenting the three defensive layers. All 46 tests pass locally on macOS 14 with bats-core 1.13.0. shellcheck --severity=warning is clean on all 6 hook scripts. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci-plugin.yml | 70 +++++++++ CLAUDE-CODE-PLUGIN.md | 51 +++++++ plugins/cix/scripts/session-end.sh | 24 +++ plugins/cix/scripts/session-start.sh | 27 ++++ plugins/cix/tests/README.md | 85 +++++++++++ plugins/cix/tests/cix-wrapper.bats | 62 ++++++++ plugins/cix/tests/cwd-changed.bats | 96 ++++++++++++ plugins/cix/tests/grep-nudge.bats | 117 +++++++++++++++ plugins/cix/tests/helpers.bash | 109 ++++++++++++++ plugins/cix/tests/mocks/bin/cix | 25 ++++ plugins/cix/tests/post-compact.bats | 71 +++++++++ plugins/cix/tests/session-end.bats | 163 ++++++++++++++++++++ plugins/cix/tests/session-start.bats | 212 +++++++++++++++++++++++++++ 13 files changed, 1112 insertions(+) create mode 100644 .github/workflows/ci-plugin.yml create mode 100644 plugins/cix/tests/README.md create mode 100644 plugins/cix/tests/cix-wrapper.bats create mode 100644 plugins/cix/tests/cwd-changed.bats create mode 100644 plugins/cix/tests/grep-nudge.bats create mode 100644 plugins/cix/tests/helpers.bash create mode 100755 plugins/cix/tests/mocks/bin/cix create mode 100644 plugins/cix/tests/post-compact.bats create mode 100644 plugins/cix/tests/session-end.bats create mode 100644 plugins/cix/tests/session-start.bats diff --git a/.github/workflows/ci-plugin.yml b/.github/workflows/ci-plugin.yml new file mode 100644 index 0000000..946d075 --- /dev/null +++ b/.github/workflows/ci-plugin.yml @@ -0,0 +1,70 @@ +name: Plugin Tests + +# Trigger only when plugin files change — server/CLI/dashboard work +# is unaffected and shouldn't run plugin tests. +on: + push: + branches: [main, 'feat/*', 'fix/*'] + paths: + - 'plugins/cix/**' + - '.claude-plugin/**' + - '.github/workflows/ci-plugin.yml' + pull_request: + paths: + - 'plugins/cix/**' + - '.claude-plugin/**' + - '.github/workflows/ci-plugin.yml' + +jobs: + test: + name: bats + shellcheck on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Install bats, jq, shellcheck (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y bats jq shellcheck + + - name: Install bats, jq, shellcheck (macOS) + if: runner.os == 'macOS' + run: | + brew install bats-core jq shellcheck + + - name: Verify bats version + run: bats --version + + - name: Run bats test suites + run: bats --tap plugins/cix/tests/*.bats + + - name: ShellCheck on hook scripts + run: | + # `--severity=warning` filters out style nags; `-x` follows + # sourced files (we don't source any in v0.1, but defensive). + shellcheck --severity=warning plugins/cix/scripts/*.sh + + - name: Validate JSON manifests with jq + run: | + jq . .claude-plugin/marketplace.json + jq . plugins/cix/.claude-plugin/plugin.json + jq . plugins/cix/hooks/hooks.json + + - name: Verify symlink integrity + run: | + # The bin/cix symlink MUST point at scripts/cix-wrapper.sh. + if [[ ! -L plugins/cix/bin/cix ]]; then + echo "::error::plugins/cix/bin/cix is not a symlink" + exit 1 + fi + target=$(readlink plugins/cix/bin/cix) + if [[ "$target" != "../scripts/cix-wrapper.sh" ]]; then + echo "::error::bin/cix points to '$target' (expected '../scripts/cix-wrapper.sh')" + exit 1 + fi diff --git a/CLAUDE-CODE-PLUGIN.md b/CLAUDE-CODE-PLUGIN.md index 00e7a7d..6d95f42 100644 --- a/CLAUDE-CODE-PLUGIN.md +++ b/CLAUDE-CODE-PLUGIN.md @@ -415,6 +415,57 @@ re-open Claude Code. --- +## Security & testing + +The plugin runs bash scripts on every Claude Code session, with calls +that include `find -delete` and writes to `$CLAUDE_PLUGIN_DATA`. Three +defensive layers protect against accidental damage: + +1. **Path validation guards.** Before any deletion, `session-start.sh` + and `session-end.sh` check that `$CLAUDE_PLUGIN_DATA` falls inside + one of the whitelisted prefixes: + - `$HOME/.claude/plugins/data/*` (the official plugin-data dir) + - `/tmp` or `/tmp/*` + - `$TMPDIR/*` (macOS test sandboxes) + + If the cache dir is outside this whitelist (e.g. `/`, `$HOME`, + `/etc`), the script prints a refusal message and exits non-zero + without touching anything. + +2. **Restrictive `find` patterns.** Every `find -delete` uses + `-maxdepth 1`, `-type f`, and a tight `-name` filter + (`cix-aware-*` / `cix-grep-count-*`). Subdirectories, symlinks, + and unrelated files are never touched, even within the whitelisted + cache dir. We deliberately avoid `rm -rf` anywhere in the plugin. + +3. **Automated test suite.** `plugins/cix/tests/` contains 46 + [bats-core](https://bats-core.readthedocs.io/) tests covering all 6 + hook scripts. The test matrix includes adversarial cases: + - `CLAUDE_PLUGIN_DATA=/`, `=$HOME`, `=/etc` — guard must refuse + - `session_id` containing shell metacharacters — must not inject + commands (canary file survives) + - Other sessions' cache files — must not be touched + - Random non-cix files in cache dir — must not be touched + - 30-day GC — must spare files outside the cix-prefixed patterns + - Path-with-spaces project dirs — must hash correctly + + GitHub Actions runs the suite on Ubuntu and macOS for every PR + that touches `plugins/cix/` or `.claude-plugin/`. ShellCheck runs + alongside, gating warnings. + +To run tests locally: + +```bash +brew install bats-core jq shellcheck # macOS +sudo apt-get install bats jq shellcheck # Debian / Ubuntu + +bats plugins/cix/tests/*.bats +shellcheck --severity=warning plugins/cix/scripts/*.sh +``` + +See [`plugins/cix/tests/README.md`](plugins/cix/tests/README.md) for +the full test matrix and instructions for adding new cases. + ## Roadmap **v0.2** (after v0.1 feedback): diff --git a/plugins/cix/scripts/session-end.sh b/plugins/cix/scripts/session-end.sh index 7b7efe8..31c4da7 100755 --- a/plugins/cix/scripts/session-end.sh +++ b/plugins/cix/scripts/session-end.sh @@ -29,8 +29,32 @@ fi CACHE_DIR="${CLAUDE_PLUGIN_DATA:-/tmp}" +# ── Safety guard (mirror of session-start.sh) ───────────────────────────────── +# Refuse to operate outside known-safe locations before invoking find -delete. +case "$CACHE_DIR" in + "$HOME/.claude/plugins/data"|"$HOME/.claude/plugins/data/"*) ;; + "/tmp"|"/tmp/"*) ;; + *) + if [ -n "${TMPDIR:-}" ]; then + case "$CACHE_DIR" in + "${TMPDIR%/}"|"${TMPDIR%/}"/*) ;; + *) + echo "cix plugin (session-end): refusing to operate on cache dir outside whitelist: $CACHE_DIR" >&2 + exit 1 + ;; + esac + else + echo "cix plugin (session-end): refusing to operate on cache dir outside whitelist: $CACHE_DIR" >&2 + exit 1 + fi + ;; +esac +[ -d "$CACHE_DIR" ] || exit 0 + # Glob-delete every per-(session, dir) marker. Use find for safe handling # of patterns when there are no matches (avoids "rm: ... no such file" noise). +# -maxdepth 1 + -type f + restrictive -name patterns ensures we only touch +# our own one-byte marker files. find "$CACHE_DIR" -maxdepth 1 -type f \ \( -name "cix-aware-$SESSION_ID-*" -o -name "cix-grep-count-$SESSION_ID-*" \) \ -delete 2>/dev/null || true diff --git a/plugins/cix/scripts/session-start.sh b/plugins/cix/scripts/session-start.sh index 9a4b433..5e7e7be 100755 --- a/plugins/cix/scripts/session-start.sh +++ b/plugins/cix/scripts/session-start.sh @@ -48,6 +48,33 @@ fi CACHE_DIR="${CLAUDE_PLUGIN_DATA:-/tmp}" mkdir -p "$CACHE_DIR" 2>/dev/null || CACHE_DIR="/tmp" +# ── Safety guard: refuse to operate outside known-safe locations ────────────── +# This script later runs `find -delete` for the 30-day GC. If +# CLAUDE_PLUGIN_DATA is somehow misconfigured to point at $HOME, /, /etc, +# etc., we MUST refuse to proceed. Whitelist: +# - $HOME/.claude/plugins/data/* — official plugin-persistent dir +# - /tmp or /tmp/* — ad-hoc / installer fallback +# - $TMPDIR/* — macOS BATS_TMPDIR for tests +case "$CACHE_DIR" in + "$HOME/.claude/plugins/data"|"$HOME/.claude/plugins/data/"*) ;; + "/tmp"|"/tmp/"*) ;; + *) + if [ -n "${TMPDIR:-}" ]; then + case "$CACHE_DIR" in + "${TMPDIR%/}"|"${TMPDIR%/}"/*) ;; + *) + echo "cix plugin (session-start): refusing to operate on cache dir outside whitelist: $CACHE_DIR" >&2 + exit 1 + ;; + esac + else + echo "cix plugin (session-start): refusing to operate on cache dir outside whitelist: $CACHE_DIR" >&2 + exit 1 + fi + ;; +esac +[ -d "$CACHE_DIR" ] || exit 0 + PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" # Hash the project dir so the cache file name is short and stable. diff --git a/plugins/cix/tests/README.md b/plugins/cix/tests/README.md new file mode 100644 index 0000000..65307f4 --- /dev/null +++ b/plugins/cix/tests/README.md @@ -0,0 +1,85 @@ +# Plugin tests + +Hook script tests for the cix Claude Code plugin. Uses +[bats-core](https://bats-core.readthedocs.io/) with mocked `cix` binary, +isolated `$CLAUDE_PLUGIN_DATA`, and a per-test scratch project directory. + +## Run locally + +```bash +# Install bats + jq + shellcheck +brew install bats-core jq shellcheck # macOS +sudo apt-get install bats jq shellcheck # Debian / Ubuntu + +# From repo root: +bats plugins/cix/tests/*.bats + +# Or pick one suite: +bats plugins/cix/tests/session-end.bats + +# TAP-formatted output (what CI uses): +bats --tap plugins/cix/tests/*.bats +``` + +Each test runs in an isolated `$BATS_TMPDIR` scratch directory and +cleans up after itself — no state leaks between tests. + +## What's covered + +| Suite | Focus | +|---|---| +| `session-start.bats` | cix-status flow, cache write, GC, **path validation guards** | +| `cwd-changed.bats` | First-cd evaluation, no-op on cached dir, multi-dir state | +| `grep-nudge.bats` | Exponential backoff (1, 2, 4, 8, 16), per-(session, dir) counters | +| `post-compact.bats` | Re-injection only when cache="1" | +| `session-end.bats` | **Security:** glob deletion never leaks beyond own session, beyond non-cix files, or beyond expected dirs | +| `cix-wrapper.bats` | System-cix passthrough, exit code propagation, self-recursion guard | + +## Security tests (the most important ones) + +Bash scripts that call `find -delete` and `rm` get extra scrutiny. +The `session-end.bats` and `session-start.bats` suites contain explicit +adversarial cases: + +- `CLAUDE_PLUGIN_DATA=/` → script must `exit 1` with "refusing to operate" +- `CLAUDE_PLUGIN_DATA=$HOME` → same refusal +- `CLAUDE_PLUGIN_DATA=/etc` → same refusal +- Other sessions' cache files → must NOT be touched +- Random non-cix files in cache dir → must NOT be touched +- Subdirectories in cache dir → must NOT be touched (only `-maxdepth 1`) +- 30-day GC → must spare files outside the `cix-aware-*` / `cix-grep-count-*` + patterns, even if they're old +- `session_id` containing shell metacharacters → must NOT trigger + command injection (canary file survives) + +If any of these fail in CI, the offending change cannot land. + +## Mocks + +`tests/mocks/bin/cix` is a fake `cix` CLI controlled via env vars: + +- `MOCK_CIX_EXIT` — exit code (default 0) +- `MOCK_CIX_DELAY` — sleep before exit (for timeout tests) +- `MOCK_CIX_LOG_FILE` — append every invocation here so tests can + assert "was the script called with the right args?" + +`helpers.bash` puts the mock first on `$PATH` for every hook invocation, +so unqualified `cix` calls inside the hook scripts hit the mock. + +## Adding a new test + +1. Pick (or create) the right `.bats` file. +2. Use `setup() { setup_test_env; }` and `teardown() { teardown_test_env; }`. +3. Use `run_hook