diff --git a/AGENTS.md b/AGENTS.md
index 5b02689..abb7566 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,10 +8,11 @@ code.
## Project overview
**code-context is local code search for AI coding agents: a CLI (`cx`) and an
-MCP server over a ranked index that lives in plain files inside the repo.** It
-fuses keyword (BM25) and semantic (vector) search into one ranked pass and
-exposes read-only SQL over the index, so an agent answers questions about a
-codebase without crawling files into the context window. The index is built
+MCP server over a ranked index that lives in plain files inside the repo.** The
+query surface is one door: read-only SQL whose table-valued search functions
+(hybrid_search fusing keyword BM25 + semantic ranking, bm25_search keyword-only)
+make finding, counting, and ranking code a single SELECT, so an agent answers
+questions about a codebase without crawling files into the context window. The index is built
and queried in-process with a local embedding model: no accounts, no API
keys, no server. It is built on the [infino](https://github.com/infino-ai/infino)
engine, which runs SQL, full-text, and vector search over one copy of the
@@ -24,19 +25,23 @@ the honest limits in [docs/tradeoffs.md](docs/tradeoffs.md).
## Repo map
- `src/cli.ts`: the `cx` / `code-context` command entry (commander).
-- `src/mcp/server.ts`: the MCP server, three tools (`search`, `sql`,
- `reindex`). Each takes an optional `path` (repo root) so one server serves
- multiple repos in a session, defaulting to the startup root.
+- `src/mcp/server.ts`: the MCP server, two tools (`sql`, `reindex`). Each
+ takes an optional `path` (repo root) so one server serves multiple repos
+ in a session, defaulting to the startup root. The index builds eagerly at
+ server startup.
- `src/mcp/repos.ts`: the per-repo registry - resolves and validates a
requested root, one engine connection per repo, LRU-capped.
-- `src/mcp/ensure.ts`: auto-index on first query - a `search`/`sql` on a
- never-indexed repo builds the index inline, then answers on the same call
- (`CX_AUTO_INDEX=0` restores the strict "index it first" error).
+- `src/mcp/ensure.ts`: auto-index safety net - a `sql` query that reaches a
+ never-indexed repo before the eager startup build builds the index inline,
+ then answers on the same call (`CX_AUTO_INDEX=0` restores the strict
+ "index it first" error).
- `src/core/`: the engine-facing core. `chunker` (tree-sitter chunking),
`indexer` (build + staged readiness + incremental sync), `searcher`
- (hybrid search + SQL), `embedder` (local model), `filestate` (incremental
+ (SQL + embed-placeholder plumbing), `embedder` (local model), `filestate` (incremental
sync state), `walker`, `manifest`, `config`, `context`, `output`.
-- `src/commands/`: CLI command implementations (`index-cmd`, `query-cmds`).
+- `src/commands/`: CLI command implementations (`index-cmd`, `query-cmds`,
+ and `install-cmd`, which wires the enforcement hooks into a non-plugin
+ client's settings).
- `test/`: vitest suites. `bench/`: the benchmark harness. `docs/`: docs.
## Build, test, gates
@@ -53,11 +58,13 @@ before opening a PR.
## Conventions
- TypeScript, ES modules. Every source file carries an SPDX header.
-- The MCP surface is deliberately three tools: one way to find (`search`),
- one way to count (`sql`), one way to stay fresh (`reindex`). Adding
- near-duplicate retrieval tools worsens an agent's tool selection; resist it.
-- Search results carry chunk content plus `path:line` ranges so answers cite
- code; keep that contract when touching `searcher` or the tool descriptions.
+- The MCP surface is deliberately two tools: one way to query (`sql`, with
+ ranked retrieval as table-valued functions), one way to stay fresh
+ (`reindex`). Adding near-duplicate retrieval tools worsens an agent's tool
+ selection; resist it.
+- Query results carry chunk content plus `path`/`start_line`/`end_line` so
+ answers cite code; keep that contract when touching `searcher` or the tool
+ descriptions.
## Boundaries
diff --git a/README.md b/README.md
index f44baa3..51ae0ea 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ Install the Claude Code plugin - nothing to paste into a config:
/plugin install code-context@infino-ai
```
-It registers code-context's three tools with `alwaysLoad` already set, so the
+It registers code-context's two tools with `alwaysLoad` already set, so the
agent keeps them in view and reaches for the index directly instead of falling
back to plain file search.
@@ -75,10 +75,11 @@ servers - where clients defer tool definitions behind a tool-search step - the
agent doesn't miss the index and fall back to plain file search. (Use *either*
the plugin or this command, not both.)
-Then just ask a question about the code. The first `search` or `sql` on an
-unindexed repo builds the index inline and answers on the same call: keyword
-search is live in seconds, and vectors backfill in the background. (Prefer to
-kick it off yourself? The `reindex` tool does the same build on demand.)
+Then just ask a question about the code. The server builds the index as it
+starts, so keyword search is live within seconds of the session opening and
+vectors backfill in the background - the first `sql` query typically finds the
+index already waiting. (A query that beats the build still triggers it inline;
+`reindex` syncs after big edits.)
CI-tested on Linux x64 (glibc) and macOS arm64; linux-arm64, musl, and
Windows-via-WSL are expected to work through the engine's prebuilt bindings
@@ -118,19 +119,25 @@ One index and a deliberately small tool surface for agents:
| Tool | What it does | When agents use it |
|---|---|---|
-| `search` | One ranked pass fusing exact keyword matching (BM25) with semantic similarity (reciprocal-rank fusion). Hits carry the chunk content, so answers come straight from results. | A strong default for finding and understanding code: how a subsystem works, code by meaning or exact term, context before a change, similar implementations - exact identifiers and paraphrases in the same call. |
-| `sql` | Read-only SQL over the index, with the ranked search functions (`bm25_search`/`hybrid_search`) usable as table-valued relations. | Counts, rankings, aggregates over the whole repo in one query. |
+| `sql` | Read-only SQL over the index, with ranked retrieval as table-valued relations: `hybrid_search` fuses exact keyword matching (BM25) with semantic similarity in one ranked pass; `bm25_search` is the keyword arm alone. Rows carry the chunk content, so answers come straight from results. | Everything: how a subsystem works, code by meaning or exact term, context before a change, and any count/ranking/aggregate over the whole repo - one query language for all of it. |
| `reindex` | Incremental sync (the server also auto-syncs in the background). | After significant edits. |
-Three tools is a deliberate design: one way to find, one way to count, one
-way to stay fresh. Every additional near-duplicate retrieval tool worsens an
-agent's tool selection, and hybrid search's keyword half already ranks
-exact identifier terms highly, so a separate lexical tool has no job left.
+Two tools is a deliberate design: one way to query, one way to stay fresh.
+Every additional near-duplicate retrieval tool worsens an agent's tool
+selection - and since hybrid ranking's keyword half already ranks exact
+identifier terms highly, retrieval needs no second door beside SQL.
-### The SQL move
+### One surface, both jobs
-Search-as-a-table composes with aggregation. Ranked by relevance, tallied by
-SQL, one engine pass:
+Finding code is a query:
+
+```sql
+SELECT path, start_line, end_line, symbol, content
+FROM hybrid_search('chunks', 'content', 'auth handling', 'embedding', {{q}}, 10)
+```
+
+and because search is a table, the same relation composes with aggregation -
+ranked by relevance, tallied by SQL, one engine pass:
```sql
SELECT path, SUM(end_line - start_line + 1) AS lines, COUNT(*) AS chunks
@@ -138,9 +145,9 @@ FROM bm25_search('chunks', 'content', 'vector index quantization', 300)
GROUP BY path ORDER BY lines DESC LIMIT 15
```
-`hybrid_search(...)` and `vector_search(...)` work the same way. The CLI and
-MCP server embed `{{name}}` placeholders server-side, so agents never handle
-raw vectors.
+`vector_search(...)` works the same way when you want meaning-only ranking.
+The CLI and MCP server embed `{{name}}` placeholders server-side, so agents
+never handle raw vectors.
### Staged readiness
@@ -149,7 +156,9 @@ that takes under a second, so search works before any embedding model even
exists on the machine. Vectors backfill in the background with a local model
(downloaded once, no key; about two minutes for that same repo), and
hybrid/semantic ranking unlocks automatically when they land. If the vector
-stage fails, keyword search stays live and the index says so honestly.
+stage fails, keyword search stays live and the index says so honestly. The
+MCP server kicks this build off as it starts, so the index is typically live
+before an agent's first query.
The default model optimizes quality-per-minute. See
[docs/embedder-eval.md](docs/embedder-eval.md) for how it was chosen.
@@ -164,7 +173,7 @@ export and pass around.
## Setup for agents
code-context is an MCP server over stdio, so any MCP client works. Register
-it once and the tools (`search`, `sql`, `reindex`) become available to the
+it once and the tools (`sql`, `reindex`) become available to the
agent.
@@ -188,12 +197,28 @@ claude mcp add-json code-context -s user '{"command":"npx","args":["-y","@infino
for the index directly. In sessions with many MCP servers Claude Code defers
tool definitions behind a tool-search step; without `alwaysLoad` the agent can
miss code-context and fall back to grep/read. It's a small, always-loaded set
-(three tools). Omit it (or use the shorter `claude mcp add code-context -- npx
+(two tools). Omit it (or use the shorter `claude mcp add code-context -- npx
-y @infino-ai/code-context mcp`) if you'd rather leave the tools deferred.
Use *either* the plugin or the `add-json` command, not both. They register the
same `code-context` server, so running both just collides.
+**Enforcement.** The plugin also ships hooks that make the index the default
+way to search: once a repo's index fully covers it (vectors ready, nothing
+over the file cap), the Grep tool and standalone `grep`/`rg`/`git grep`
+commands are denied with a redirect to the `sql` search functions. Until the
+index is fully built, grep is untouched - enforcement never pushes an agent
+onto an index that can't answer yet. The deny is scoped, not absolute - grep
+as a pipe filter on other command output always passes, and a grep targeting
+something the index can't answer for (a gitignored file, a file over the byte
+cap, a dot-path, a path outside the repo) is allowed silently. Two escape
+hatches: prefix a command with `CX_GREP_FALLBACK=1` when an index search
+genuinely came up short (the hook asks for approval instead of denying), and
+`CX_NO_ENFORCE=1` in the environment disables enforcement entirely. Plain MCP
+registration (`add-json`) gets the tools without the hooks; `cx install`
+adds them (see below). Use *either* the plugin or `cx install`, not both -
+both sets fire, harmlessly, but it's noise.
+
**For a team,** commit a project-scoped `.mcp.json` at the repo root so
everyone gets it (after the one-time project-server approval):
@@ -252,7 +277,7 @@ when the client's working directory is not the repo.
-Tools: `search`, `sql`, `reindex` (incremental sync: an unchanged repo is
+Tools: `sql`, `reindex` (incremental sync: an unchanged repo is
a fast no-op, and the server also auto-syncs in the background as queries
arrive, so results track your edits without anyone asking).
@@ -262,24 +287,46 @@ target a specific repo when a session spans more than one. One server
instance serves them all, each with its own index in its own `.infino/` -
no restart, no per-repo config.
+**Enforcement without the plugin.** Enforcement - hooks that deny the Grep
+tool and standalone `grep`/`rg`/`git grep` on a fully-indexed repo and
+redirect to `sql`, with the full rules in the Claude Code block above - ships
+inside the plugin. For every other way of reaching code-context - `npx`,
+`claude mcp add-json`, Cursor, Windsurf - `cx install` wires the same hooks,
+and there it's the only form that survives a client restart:
+
+```
+cx install # or: npx -y @infino-ai/code-context install
+```
+
+It copies the hook to `~/.claude/hooks/cx-deny-grep.mjs` and merges
+`SessionStart` + `PreToolUse` entries into `~/.claude/settings.json`, leaving
+any other hooks in the file alone. Re-running it is idempotent, `cx install
+--uninstall` reverses it, and `--settings ` targets a different settings
+file. The entries embed the absolute path of the `node` that ran the install,
+because a client's process often has no `node` on `PATH` and a hook that
+can't find node fails silently - which reads exactly like enforcement not
+working. Start a new session for the client to pick the hooks up. Use the
+plugin or this command, not both.
+
## Configuration
| Variable | Default | Purpose |
|---|---|---|
| `CX_INDEX_DIR` | `/.infino` | where the index lives |
-| `CX_SEARCH_K` | 10 | default number of hits `search` returns (also settable per call and via the CLI `-k` flag) |
-| `CX_MAX_FILES` / `CX_MAX_FILE_BYTES` | 20000 / 1MB | indexing caps (files over the file cap are left out; `search`/`sql` then flag the index as partial so an absence isn't read as proof) |
+| `CX_MAX_FILES` / `CX_MAX_FILE_BYTES` | 20000 / 1MB | indexing caps (files over the file cap are left out; `sql` then flags the index as partial so an absence isn't read as proof) |
| `CX_ROOT` | current directory | default repo root for the MCP server / CLI when not run from the repo (each tool call can override it with a `path` argument) |
-| `CX_AUTO_INDEX` | on | `0` makes a query on an unindexed repo error instead of building the index inline on the first `search`/`sql` |
+| `CX_AUTO_INDEX` | on | `0` makes a query on an unindexed repo error instead of building eagerly at server startup (or inline on the first `sql`) |
| `CX_AUTO_SYNC` | on | `0` disables the MCP server's background staleness sync |
| `CX_SYNC_INTERVAL_SECS` | 30 | auto-sync debounce between staleness checks |
| `CX_NO_EMBED` | off | keyword-only mode for the MCP server (skip the vector stage) |
| `CX_NO_RECEIPT` | off | `1` turns off usage accounting - the per-call receipt on results and the `cx usage` ledger |
+| `CX_NO_ENFORCE` | off | `1` disables the grep-enforcement hooks entirely (however they were installed - the plugin or `cx install`) |
+| `CX_GREP_FALLBACK` | - | prefix a `grep`/`rg` command with `CX_GREP_FALLBACK=1` to request an approved fallback grep after an index search came up short |
-Every `search` / `sql` result carries a **usage receipt** - a terse, local line
-showing the tokens it returned, the files it spanned, and a running session
-total (e.g. `returned ~1.2k tokens | 4 chunks / 3 files | session ~8.4k over 7
-queries`). Every figure is a `~` estimate, computed in-process - nothing about
+Every `sql` result carries a **usage receipt** - a terse, local line showing
+the tokens it returned, the row count, and a running session total (e.g.
+`returned ~1.2k tokens | 12 rows | invoked 7x this session (~8.4k tokens
+total)`). Every figure is a `~` estimate, computed in-process - nothing about
your queries or code leaves the machine.
## CLI
@@ -294,17 +341,17 @@ npm install -g @infino-ai/code-context
```
cx index [path] sync the index (incremental; --full rebuilds, --watch follows edits)
-cx search exact terms + meaning, one ranked pass (-k hits)
cx sql read-only SQL; --embed q="text" fills {{q}}
cx status what the index holds, how fresh, vector readiness
cx usage ledger of queries run and what each returned (-n, --all, --clear, --json)
cx mcp serve the MCP tools over stdio
+cx install wire the enforcement hooks into the client (--uninstall reverses)
```
-`cx usage` reads the local ledger at `.infino/usage.jsonl` - every `search` /
-`sql` (from the CLI or the MCP server) appends one line recording the query and
-a compact summary of what came back (paths and line ranges for search, row
-count for sql), plus the token figures from the receipt. It's a deterministic,
+`cx usage` reads the local ledger at `.infino/usage.jsonl` - every `sql` query
+(from the CLI or the MCP server) appends one line recording the statement and
+a compact summary of what came back (row count and a preview of the rows),
+plus the token figures from the receipt. It's a deterministic,
model-independent view of what went through the index - no running server or
agent needed to read it back. `CX_NO_RECEIPT=1` turns off both the inline
receipt and this ledger.
diff --git a/bench/recall.mjs b/bench/recall.mjs
index ffcb2a2..26a712b 100644
--- a/bench/recall.mjs
+++ b/bench/recall.mjs
@@ -3,7 +3,7 @@
// Indexes THIS repo (code-context) to a temp dir with the default local
// embedder, then runs a fixed set of paraphrase queries whose gold file is
// known, and reports hit@5 / MRR@5 for vector-only ranking (isolates the
-// embedder) and hybrid ranking (the `search` tool's real surface).
+// embedder) and hybrid ranking (the sql tool's hybrid_search surface).
//
// It's deterministic (same model+dtype -> same vectors) and needs no network
// beyond the one-time model download, which makes it a good regression signal.
@@ -42,7 +42,7 @@ process.env.CX_INDEX_DIR = tmp;
const { indexRepo } = await import(`${ROOT}/dist/core/indexer.js`);
const { openForIndexing, openIndex } = await import(`${ROOT}/dist/core/context.js`);
const { createEmbedder } = await import(`${ROOT}/dist/core/embedder.js`);
-const { search } = await import(`${ROOT}/dist/core/searcher.js`);
+const { runSql } = await import(`${ROOT}/dist/core/searcher.js`);
const { TABLE, DEFAULT_CAPS } = await import(`${ROOT}/dist/core/config.js`);
console.log(`indexing ${ROOT}`);
@@ -69,7 +69,14 @@ for (const [q, gold] of GOLD) {
const [vec] = await embedder.embed([q]);
const vRank = rankOf(table.vectorSearch("embedding", vec, 15, { projection: ["path"] }), gold);
if (vRank >= 1 && vRank <= 5) { vHit++; vRr += 1 / vRank; }
- const hRank = rankOf((await search(handle, embedder, q, 15)).hits, gold);
+ const esc = q.replaceAll("'", "''");
+ const hRows = await runSql(
+ handle,
+ embedder,
+ `SELECT path FROM hybrid_search('${TABLE}','content','${esc}','embedding', {{q}}, 15)`,
+ { q },
+ );
+ const hRank = rankOf(hRows, gold);
if (hRank >= 1 && hRank <= 5) { hHit++; hRr += 1 / hRank; } else misses.push(`${gold} ("${q}")`);
}
diff --git a/docs/faq.md b/docs/faq.md
index 6a42a0e..17b2c95 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -3,10 +3,10 @@
### What is code-context?
Local code search for AI coding agents: a CLI (`cx`) and an MCP server over a
-ranked index that lives in plain files inside your repo. It fuses keyword
-(BM25) and semantic search in one pass and exposes read-only SQL over the
-index, so an agent answers questions about the codebase without reading it
-file by file.
+ranked index that lives in plain files inside your repo, queried through one
+door - read-only SQL whose table-valued search functions fuse keyword (BM25)
+and semantic ranking in one pass - so an agent answers questions about the
+codebase without reading it file by file.
### When should an agent use it instead of grep?
@@ -16,6 +16,29 @@ meaning when you do not know the identifier, and ranking or aggregating
across the whole repo. For jumping to one known symbol or literal string, a
plain grep is already cheap and there is no need for an index.
+### How do I make an agent actually use the index instead of grep?
+
+Install the enforcement hooks. The Claude Code plugin ships them; on any other
+client - `npx`, `claude mcp add-json`, Cursor, Windsurf - `cx install` writes
+them, and there it is the only form that survives a client restart (use one or
+the other, not both). It copies the hook to `~/.claude/hooks/cx-deny-grep.mjs`
+and merges `SessionStart` + `PreToolUse` entries into
+`~/.claude/settings.json` - idempotent, reversed by `cx install --uninstall`,
+and pointed at another file with `--settings `. The entries embed the
+absolute path of the `node` that ran the install, because a client's process
+often has no `node` on `PATH` and a hook that cannot find node fails silently.
+
+With the hooks in place, once a repo's index fully covers it (vectors ready,
+nothing over the file cap) the Grep tool and standalone `grep`/`rg`/`git grep`
+are denied with a redirect to the `sql` search functions. Until the index is
+fully built grep is untouched - enforcement never pushes an agent onto an
+index that cannot answer yet. The deny is scoped, too: grep as a pipe filter
+on other command output always passes, and a target the index cannot cover
+(gitignored, over the byte cap, a dot-path, outside the repo) passes
+silently. Prefix a command with
+`CX_GREP_FALLBACK=1` to turn a deny into an approval prompt when an index
+search genuinely came up short; `CX_NO_ENFORCE=1` disables enforcement.
+
### Does my code leave the machine?
No. There are no accounts, no API keys, and no server. The embedding model is
@@ -32,11 +55,11 @@ reports that honestly rather than failing.
### Do I have to index before I can search?
-No. The first `search` or `sql` on a repo that has never been indexed builds
-the index inline and answers on that same call - keyword search is live in
-seconds, vectors backfill behind it. Call `reindex` first if you'd rather
-kick the build off explicitly, or set `CX_AUTO_INDEX=0` to make an unindexed
-query return a "index it first" error instead of building.
+No. The MCP server builds the index as it starts, so it is typically live
+before the first query - and a `sql` query that beats the build still
+triggers it inline and answers on the same call. Keyword search is live in
+seconds, vectors backfill behind it. Set `CX_AUTO_INDEX=0` to disable both
+and make an unindexed query return an "index it first" error instead.
### Can one server handle more than one repo?
@@ -63,20 +86,20 @@ no-op. The MCP server also auto-syncs in the background as queries arrive.
Indexing caps how many files it takes (`CX_MAX_FILES`, default 20,000); files
past the cap are left out. When that happens the index is marked partial:
-every `search` and `sql` result carries a `partial` note with how many files
-were skipped and the cap in effect, so an agent treats a missing match as
-"maybe not indexed" rather than "not in the repo." `cx status` shows the same,
-and `cx search` prints a warning. Raise `CX_MAX_FILES` (CLI: `--max-files`)
-and re-index for full coverage.
+every `sql` result carries a `partial` note with how many files were skipped
+and the cap in effect, so an agent treats a missing match as "maybe not
+indexed" rather than "not in the repo." `cx status` shows the same. Raise
+`CX_MAX_FILES` (CLI: `--max-files`) and re-index for full coverage.
### What tools does the MCP server expose?
-Three, by design: `search` (hybrid keyword + semantic retrieval, one ranked
-pass, hits carry chunk content with `path:line` ranges), `sql` (read-only
-`SELECT`/`WITH` over the index, with the ranked search functions usable as
-table-valued relations so search composes with `GROUP BY`), and `reindex`
-(incremental sync). Every additional near-duplicate retrieval tool worsens an
-agent's tool selection, so the surface is kept deliberately small.
+Two, by design: `sql` (read-only `SELECT`/`WITH` over the index, with the
+ranked search functions - `hybrid_search`, `bm25_search`, `vector_search` -
+usable as table-valued relations, so one query finds code by meaning or exact
+term AND composes with `GROUP BY` for counts and rankings; rows carry chunk
+content with `path:line` ranges) and `reindex` (incremental sync). Every
+additional near-duplicate retrieval tool worsens an agent's tool selection,
+so retrieval lives inside SQL rather than beside it.
### How is SQL over code useful?
diff --git a/hooks/deny-grep.mjs b/hooks/deny-grep.mjs
new file mode 100644
index 0000000..26a60e8
--- /dev/null
+++ b/hooks/deny-grep.mjs
@@ -0,0 +1,712 @@
+#!/usr/bin/env node
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright The Infino Authors
+//
+// code-context enforcement hook: steer code search to the sql tool without
+// trapping the agent.
+//
+// Grep (the tool, or a standalone grep/rg/git-grep Bash command) is denied
+// with a redirect to the sql TVFs ONLY when all of these hold:
+// - the repo's index is fully live: manifest present, vectors "ready",
+// nothing truncated by the file cap, and the table actually on disk
+// (a manifest orphaned by a deleted table fails open);
+// - the index actually contains what the command searches. Coverage is
+// never re-derived from the indexer's skip rules: any drift between the
+// hook's model and the indexer's behaviour denies grep on a file sql
+// cannot answer for either, which costs the agent both paths. The covered
+// file set is read straight out of `.infino/filestate.json`, which every
+// build and sync writes - keys whose entry recorded zero chunk rows are
+// dropped, because that state fingerprints files it could not chunk
+// (binary bytes behind an indexable extension, an empty `__init__.py`) and
+// the table holds no rows for them;
+// - the command does not carry the CX_GREP_FALLBACK=1 marker - with the
+// marker the decision is "ask", the human-approved fallback for when an
+// index search genuinely came up short.
+// Pipe-filter grep (cargo test | grep FAILED) never matches; CX_NO_ENFORCE=1
+// disables enforcement entirely. Every ambiguity fails open (allow).
+//
+// The command is read as shell, not as text: heredoc bodies are removed before
+// anything is parsed (a script being written must not be read as commands the
+// agent is running), segments split on `&&`/`||`/`;`/newline but never on `|`,
+// and a launcher's operands end at a `|` or a `#` comment - otherwise the words
+// of a pipe filter (`| head -5`) read as targets nothing covers and the whole
+// search falls open. Launchers are matched on their basename, behind wrappers
+// (`command`, `timeout 5`, `xargs -a file`) and behind git's global flags.
+//
+// One script serves SessionStart and the PreToolUse matchers, dispatching on
+// the payload. The legacy `search` tool (pre-0.2 servers) is denied with a
+// redirect to sql.
+import { readFileSync, readdirSync, existsSync } from "node:fs";
+import { join, dirname, resolve, relative, isAbsolute, basename, sep } from "node:path";
+
+/** Index format the hook understands; anything else reads as absent. */
+const INDEX_FORMAT_VERSION = 2;
+
+/** The index directory and the two files read out of it (mirrors
+ * src/core/config.ts and src/core/filestate.ts). */
+const INDEX_DIR_NAME = ".infino";
+const MANIFEST_NAME = "codecontext.json";
+const FILESTATE_NAME = "filestate.json";
+
+/** Commands that launch a code search. `git` counts only as `git grep`. */
+const GREP_LAUNCHERS = new Set(["rg", "grep"]);
+
+/** Wrappers that sit in front of the real command; each one used to hide a
+ * launcher from enforcement, so they are stepped over. */
+const COMMAND_WRAPPERS = new Set(["env", "sudo", "time", "nice", "command", "xargs", "nohup", "stdbuf"]);
+
+/** A wrapper flag that eats the next token as its value (`xargs -a list`,
+ * `nice -n 5`, `env -u VAR`). Without these the scan stops on the value and
+ * the launcher behind it escapes. */
+const WRAPPER_VALUE_FLAGS = {
+ env: new Set(["-u", "-S", "-C", "-P"]),
+ sudo: new Set(["-u", "-g", "-p", "-h", "-U", "-r", "-t", "-T", "-C", "-D", "-R"]),
+ xargs: new Set(["-a", "-d", "-E", "-I", "-i", "-L", "-l", "-n", "-P", "-s", "-S"]),
+ nice: new Set(["-n"]),
+ timeout: new Set(["-k", "-s"]),
+ stdbuf: new Set(["-i", "-o", "-e"]),
+ time: new Set([]),
+ command: new Set([]),
+ nohup: new Set([]),
+};
+
+/** Shell control words and group openers that sit in front of the command
+ * that actually runs (`if rg -q …; then`, `while rg …; do`, `{ rg …; }`,
+ * `! rg …`, `( rg … )`): each one read as the launcher and hid the search. */
+const CONTROL_PREFIXES = new Set([
+ "if", "then", "else", "elif", "fi",
+ "do", "done", "while", "until", "for",
+ "!", "{", "}", "(", ")",
+]);
+
+/** `timeout` takes a duration operand before the command it runs. Only this
+ * shape is stepped over; anything else leaves the scan where it is, which
+ * reads as "not a launcher" and allows. */
+const TIMEOUT_WRAPPER = "timeout";
+const TIMEOUT_DURATION = /^\d+(?:\.\d+)?[smhd]?$/;
+
+/** git's global options that take a separate value (`git -C dir grep ...`);
+ * every other global option is a lone flag (`--no-pager`). */
+const GIT_LAUNCHER = "git";
+const GIT_SUBCOMMAND = "grep";
+const GIT_VALUE_FLAGS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"]);
+
+/** `VAR=value`, the other prefix a launcher hides behind. */
+const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
+
+/** The human-approved fallback marker, as a leading assignment. */
+const FALLBACK_MARKER = /^CX_GREP_FALLBACK=1$/;
+
+/** A target carrying any of these is a glob, matched against the indexed file
+ * set rather than resolved as a path. */
+const GLOB_CHARS = /[*?[{]/;
+
+/** An output redirection (`>`, `>>`, `2>`, `&>`), whose operand names a
+ * destination file rather than a search target. */
+const OUT_REDIRECT = /^(\d*|&)(>>?)$/;
+
+/** Anything that begins a redirection, joined to its operand or not. */
+const REDIRECT_START = /^(\d*|&)(>>?|<)/;
+
+/** A heredoc introducer at the scan position: `< p.split(sep).join("/");
+
+/** True when a root-relative, "/"-separated path leads out of the repo. */
+const outsideRepo = (rel) => rel === ".." || rel.startsWith("../") || isAbsolute(rel);
+
+/** The command a token names: its basename, so an absolute path
+ * (`/usr/bin/rg`) counts as the launcher it runs. */
+const commandName = (text) => basename(toPosix(text ?? ""));
+
+/** Nearest ancestor of `from` holding an index manifest, or undefined. */
+function findIndexRoot(from) {
+ for (let dir = from; ; dir = dirname(dir)) {
+ if (existsSync(join(dir, INDEX_DIR_NAME, MANIFEST_NAME))) return dir;
+ if (dirname(dir) === dir) return undefined;
+ }
+}
+
+/** Which tree the index describes. `manifest.root` is the only source when the
+ * index was not found by walking up from cwd (a custom CX_INDEX_DIR), where a
+ * cwd guess would read the repo's own files as "outside the repo" and allow
+ * everything. But it is only trusted while it agrees with where the index was
+ * found: a copied checkout (`cp -a repo repo2`), or an index built at a
+ * container path and read from the host, carries a manifest pointing at the
+ * original tree - trusting that would resolve every target outside the repo
+ * and silently stop enforcing while sql answers fine. */
+function repoRoot(manifestRoot, walkRoot, foundByWalk, from) {
+ if (!foundByWalk) return manifestRoot ?? walkRoot ?? from;
+ if (manifestRoot && resolve(manifestRoot) === resolve(walkRoot)) return manifestRoot;
+ return walkRoot;
+}
+
+/** Locate the index for cwd (CX_INDEX_DIR override, else walk up to .infino)
+ * and classify it. Only "ready" enforces; everything else fails open. */
+function indexInfo(cwd) {
+ const from = cwd || process.cwd();
+ const walkRoot = findIndexRoot(from);
+ const walkIndexDir = walkRoot ? join(walkRoot, INDEX_DIR_NAME) : undefined;
+ const indexDir = process.env.CX_INDEX_DIR ?? walkIndexDir;
+ const fallbackRoot = walkRoot ?? from;
+ if (!indexDir) return { state: "none", root: fallbackRoot };
+ const foundByWalk = walkIndexDir !== undefined && resolve(indexDir) === resolve(walkIndexDir);
+ try {
+ const m = JSON.parse(readFileSync(join(indexDir, MANIFEST_NAME), "utf8"));
+ const manifestRoot = typeof m.root === "string" && m.root ? m.root : undefined;
+ const root = repoRoot(manifestRoot, walkRoot, foundByWalk, from);
+ if (m.version !== INDEX_FORMAT_VERSION) return { state: "none", root, indexDir };
+ if (m.truncatedFiles) return { state: "partial", root, indexDir };
+ if (m.vectors !== "ready") return { state: "building", root, indexDir };
+ // A "ready" manifest orphaned by a deleted/moved table would deny grep
+ // while sql also errors - losing both paths. Confirm the table exists.
+ const hasTable = readdirSync(indexDir).some((n) => n.startsWith(`${m.table}-`));
+ return { state: hasTable ? "ready" : "none", root, indexDir };
+ } catch {
+ return { state: "none", root: fallbackRoot, indexDir };
+ }
+}
+
+/** Did this filestate entry put rows in the table? A recorded count of 0 means
+ * the indexer fingerprinted the file but chunked nothing out of it, so sql
+ * cannot answer for it and grep must stay allowed. An absent count is older
+ * state that never recorded one, which stays covered (back-compatible); any
+ * other shape is unreadable and fails open. */
+function entryHasChunks(entry) {
+ if (typeof entry !== "object" || entry === null) return false;
+ const n = entry.chunks;
+ if (n === undefined) return true;
+ return typeof n === "number" && n > 0;
+}
+
+/** The covered file set: repo-root-relative, "/"-separated keys of
+ * `.infino/filestate.json` whose entry actually produced chunk rows.
+ * undefined when the state is missing, unparseable, or leaves nothing covered
+ * - each of which means the hook cannot tell what sql can answer, and so must
+ * fail open. */
+function indexedFiles(indexDir) {
+ try {
+ const state = JSON.parse(readFileSync(join(indexDir, FILESTATE_NAME), "utf8"));
+ if (state?.version !== 1 || typeof state.files !== "object" || state.files === null) return undefined;
+ const entries = Object.values(state.files);
+ // A state written before counts existed has no `chunks` field anywhere, so
+ // it cannot distinguish a chunk-less file from a covered one - treating it
+ // as covered denied greps sql could not answer. Fail open until a rebuild
+ // or the first sync stamps counts; a MIXED state (some entries stamped)
+ // keeps the back-compatible reading for the unstamped rest.
+ if (entries.length > 0 && entries.every((e) => typeof e !== "object" || e === null || e.chunks === undefined)) {
+ return undefined;
+ }
+ const keys = Object.keys(state.files).filter((k) => entryHasChunks(state.files[k]));
+ return keys.length > 0 ? keys : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** Translate a glob into an anchored RegExp over "/"-separated paths: `*` and
+ * `?` stay inside one segment, `**` crosses separators, `{a,b}` alternates,
+ * `[...]` is a character class. undefined for a pattern that won't compile,
+ * which reads as "matches nothing" and so allows the grep. */
+function globRegExp(glob) {
+ let re = "";
+ let braces = 0;
+ for (let i = 0; i < glob.length; i++) {
+ const c = glob[i];
+ if (c === "*") {
+ if (glob[i + 1] === "*") {
+ // Collapse the whole run: `***…` compiles like `**`. One `.*` per pair
+ // stacked into `^.*.*.*…$`, which backtracks - 20 stars took seconds
+ // and 24 never answered, wedging the tool call until the hook timeout.
+ while (glob[i + 1] === "*") i++;
+ // `**/` also matches zero directories, so `**/*.ts` covers `a.ts`.
+ if (glob[i + 1] === "/") {
+ i++;
+ re += "(?:.*/)?";
+ } else {
+ re += ".*";
+ }
+ } else {
+ re += "[^/]*";
+ }
+ } else if (c === "?") {
+ re += "[^/]";
+ } else if (c === "{") {
+ braces++;
+ re += "(?:";
+ } else if (c === "}" && braces > 0) {
+ braces--;
+ re += ")";
+ } else if (c === "," && braces > 0) {
+ re += "|";
+ } else if (c === "[") {
+ const end = glob.indexOf("]", i + 1);
+ if (end === -1) {
+ re += "\\[";
+ continue;
+ }
+ re += `[${glob.slice(i + 1, end).replace(/^[!^]/, "^")}]`;
+ i = end;
+ } else {
+ re += REGEX_META.test(c) ? `\\${c}` : c;
+ }
+ }
+ try {
+ return new RegExp(`^${re}$`);
+ } catch {
+ return undefined;
+ }
+}
+
+/** Does the index hold at least one file this glob names? A pattern without a
+ * separator is matched against basenames too, which is how the Grep tool's
+ * `glob` input ("*.rs") is meant to read. */
+function coversGlob(pattern, base, root, keys, bareMatchesBasename) {
+ let scoped = toPosix(pattern);
+ const bare = !scoped.includes("/");
+ if (!bare) {
+ // Rebase onto the repo root so the pattern lines up with the keys. resolve
+ // and relative are pure string math here - the glob chars pass through.
+ scoped = toPosix(relative(root, isAbsolute(pattern) ? pattern : resolve(base, pattern)));
+ if (outsideRepo(scoped)) return false;
+ }
+ const re = globRegExp(scoped);
+ if (!re) return false;
+ return keys.some((k) => re.test(k) || (bare && bareMatchesBasename && re.test(basename(k))));
+}
+
+/** Does the index contain this target? A path is covered when it is a covered
+ * file, or a directory with at least one covered file under it; a glob when at
+ * least one covered key matches. Everything else is NOT covered and grep is
+ * allowed, because sql could not answer it either: a path outside the repo,
+ * anything the indexer skipped (a lockfile, a `.csv`, `LICENSE`, `vendor/`, a
+ * symlink, a file over the byte cap, a binary), and anything it fingerprinted
+ * without chunking a single row out of. */
+function coversTarget(target, cwd, root, keys) {
+ const base = cwd || root;
+ // A bare operand glob (`*.ts`) is NOT matched against basenames: the shell
+ // expands it in the cwd before rg ever runs, so basename matching read
+ // `rg zzz *.ts` as repo-wide and denied a search whose real operands were
+ // uncovered. The Grep tool's `glob` input is recursive and keeps basenames.
+ if (GLOB_CHARS.test(target)) return coversGlob(target, base, root, keys, false);
+ const rel = toPosix(relative(root, isAbsolute(target) ? target : resolve(base, target)));
+ if (outsideRepo(rel)) return false;
+ if (rel === "") return true; // the repo root itself
+ return keys.some((k) => k === rel || k.startsWith(`${rel}/`));
+}
+
+/** Remove heredoc bodies from a command line. `cat > run.sh <<'SH' … SH`
+ * writes a file: its body is data, and reading the search line inside it as a
+ * command the agent runs denies the agent its own script (with an irrelevant
+ * "use sql" message). The redirection operator and delimiter go too - what
+ * stays is the command they belong to. Quotes are respected so a `<<` inside a
+ * string is not mistaken for one; a mistake here can only drop text, which
+ * fails open. */
+function stripHeredocs(command) {
+ let out = "";
+ let quote = "";
+ const pending = [];
+ let i = 0;
+ while (i < command.length) {
+ const c = command[i];
+ if (quote) {
+ out += c;
+ if (c === quote) quote = "";
+ else if (c === "\\" && quote === '"' && i + 1 < command.length) out += command[++i];
+ i++;
+ continue;
+ }
+ if (c === '"' || c === "'") {
+ quote = c;
+ out += c;
+ i++;
+ continue;
+ }
+ if (c === "\\" && i + 1 < command.length) {
+ out += c + command[i + 1];
+ i += 2;
+ continue;
+ }
+ if (c === "<" && command[i + 1] === "<" && command[i + 2] !== "<") {
+ const m = HEREDOC_START.exec(command.slice(i));
+ if (m) {
+ pending.push({ word: m[2] ?? m[3] ?? m[4], dashed: m[1] === "-" });
+ i += m[0].length;
+ continue;
+ }
+ }
+ if (c === "\n" && pending.length > 0) {
+ out += c;
+ i++;
+ // The bodies follow in the order their delimiters appeared, each ending
+ // on its own terminator line (`<<-` allows leading tabs on it).
+ while (pending.length > 0) {
+ const { word, dashed } = pending.shift();
+ while (i < command.length) {
+ const nl = command.indexOf("\n", i);
+ const end = nl === -1 ? command.length : nl;
+ const line = command.slice(i, end);
+ i = nl === -1 ? command.length : nl + 1;
+ if ((dashed ? line.replace(HEREDOC_DASH_INDENT, "") : line) === word) break;
+ }
+ }
+ continue;
+ }
+ out += c;
+ i++;
+ }
+ return out;
+}
+
+/** Split a command line into independently-launched segments on `&&`, `||`,
+ * `;` and newlines - and NEVER on `|`: a pipe filter (`cargo test | grep
+ * FAILED`) stays one segment whose launcher is `cargo`, which is exactly why
+ * it keeps working. Quotes are respected, so a separator inside a pattern
+ * stays literal. */
+function segments(command) {
+ const out = [];
+ let cur = "";
+ let quote = "";
+ for (let i = 0; i < command.length; i++) {
+ const c = command[i];
+ if (quote) {
+ cur += c;
+ if (c === quote) quote = "";
+ else if (c === "\\" && quote === '"' && i + 1 < command.length) cur += command[++i];
+ continue;
+ }
+ if (c === '"' || c === "'") {
+ quote = c;
+ cur += c;
+ continue;
+ }
+ if (c === "\\" && i + 1 < command.length) {
+ cur += c + command[++i];
+ continue;
+ }
+ if (c === ";" || c === "\n") {
+ out.push(cur);
+ cur = "";
+ continue;
+ }
+ // `&&` and `||` split; so does a lone `&` (backgrounding: `rg pat src &`
+ // launched a search that was never inspected). `>&` (2>&1, >&2) is a
+ // redirection, not a control operator, and `|` is never a split.
+ if (c === "&") {
+ if (command[i + 1] === "&") {
+ out.push(cur);
+ cur = "";
+ i++;
+ continue;
+ }
+ if (cur.endsWith(">")) {
+ cur += c;
+ continue;
+ }
+ out.push(cur);
+ cur = "";
+ continue;
+ }
+ if (c === "|" && command[i + 1] === "|") {
+ out.push(cur);
+ cur = "";
+ i++;
+ continue;
+ }
+ cur += c;
+ }
+ out.push(cur);
+ return out.filter((s) => s.trim() !== "");
+}
+
+/** Split a segment into `{ text, quoted }` tokens, keeping quoted phrases
+ * whole: the pattern of `grep -rn "let auth" src/` is one token, so `src/`
+ * reads as the target it is. Splitting on bare whitespace made `auth"` a bogus
+ * target that fell open - which is how multi-word patterns, the normal way
+ * agents grep, escaped. An unquoted `|` is emitted as its own token: glued to
+ * a neighbour (`src|head`) it would have made a target no key matches, and the
+ * search would have fallen open. `quoted` records that some of the token was
+ * quoted or escaped, i.e. that it is text rather than shell syntax: `grep
+ * "> TODO" notes.log` searches for a literal `>`, and reading that as a
+ * redirection would drop the pattern and turn an unindexed file into a
+ * repo-wide deny. */
+function tokenize(segment) {
+ const toks = [];
+ let cur = "";
+ let started = false;
+ let quoted = false;
+ let quote = "";
+ const push = () => {
+ if (started) toks.push({ text: cur, quoted });
+ cur = "";
+ started = false;
+ quoted = false;
+ };
+ for (let i = 0; i < segment.length; i++) {
+ const c = segment[i];
+ if (quote) {
+ if (c === quote) quote = "";
+ else if (c === "\\" && quote === '"' && i + 1 < segment.length) cur += segment[++i];
+ else cur += c;
+ continue;
+ }
+ if (c === "'" || c === '"') {
+ quote = c;
+ started = true;
+ quoted = true;
+ continue;
+ }
+ if (c === "\\" && i + 1 < segment.length) {
+ cur += segment[++i];
+ started = true;
+ quoted = true;
+ continue;
+ }
+ if (/\s/.test(c)) {
+ push();
+ continue;
+ }
+ if (c === PIPE) {
+ push();
+ toks.push({ text: PIPE, quoted: false });
+ continue;
+ }
+ cur += c;
+ started = true;
+ }
+ push();
+ return toks;
+}
+
+/** Step past a wrapper's own flags, and past the value any of them takes (never
+ * past a `|`, which is syntax and not anybody's operand). Returns the index of
+ * the last token consumed. */
+function skipWrapperFlags(toks, i, wrapper) {
+ const valueFlags = WRAPPER_VALUE_FLAGS[wrapper] ?? new Set();
+ while (toks[i + 1]?.text.startsWith("-")) {
+ const flag = toks[++i].text;
+ const value = toks[i + 1];
+ if (valueFlags.has(flag) && value && value.text !== PIPE && !value.text.startsWith("-")) i++;
+ }
+ return i;
+}
+
+/** Read one segment as a grep launch, or undefined when it isn't one (a
+ * pipeline starting with another command included). `isSearch` is false for
+ * pattern-less invocations like `grep --version`; `targets` are the explicit
+ * path/glob operands, and `chdir` is git's `-C` directory, which moves the
+ * root those operands resolve against. Misparses fail open downstream. */
+function grepLaunch(segment) {
+ const toks = tokenize(segment);
+ let i = 0;
+ let fallback = false;
+ // Step over `VAR=1` prefixes and wrapper commands (with their own flags and
+ // operands) to reach the command that actually runs.
+ for (; i < toks.length; i++) {
+ const { text } = toks[i];
+ if (ENV_ASSIGNMENT.test(text)) {
+ if (FALLBACK_MARKER.test(text)) fallback = true;
+ continue;
+ }
+ if (CONTROL_PREFIXES.has(text)) continue;
+ // A group opener glued to the command (`(rg`, `{rg`, `!rg`) is shell
+ // syntax, not part of the name: strip it and re-read the token.
+ const opened = text.replace(/^[({!]+/, "");
+ if (opened !== text) {
+ if (opened === "") continue;
+ toks[i] = { ...toks[i], text: opened };
+ i--;
+ continue;
+ }
+ const wrapper = commandName(text);
+ if (COMMAND_WRAPPERS.has(wrapper)) {
+ i = skipWrapperFlags(toks, i, wrapper);
+ continue;
+ }
+ if (wrapper === TIMEOUT_WRAPPER) {
+ i = skipWrapperFlags(toks, i, TIMEOUT_WRAPPER);
+ if (toks[i + 1] && TIMEOUT_DURATION.test(toks[i + 1].text)) i++;
+ continue;
+ }
+ break;
+ }
+ const name = commandName(toks[i]?.text);
+ let chdir;
+ if (name === GIT_LAUNCHER) {
+ // git's global options sit before the subcommand. `-C dir` also moves the
+ // search root, so it is carried out: `git -C . grep auth` searches this
+ // repo, `git -C ../other grep auth` does not.
+ let j = i + 1;
+ while (toks[j]?.text.startsWith("-")) {
+ const flag = toks[j].text;
+ const joined = flag.includes("=");
+ if (flag === "-C") chdir = toks[j + 1]?.text;
+ j += GIT_VALUE_FLAGS.has(flag) && !joined ? 2 : 1;
+ }
+ if (toks[j]?.text !== GIT_SUBCOMMAND) return undefined;
+ i = j + 1;
+ } else if (GREP_LAUNCHERS.has(name)) i += 1;
+ else return undefined;
+
+ // First bare operand is the pattern; the rest are targets. A flag that eats
+ // a separate value shifts this by one, which yields a target no key matches
+ // - i.e. it fails open, never into a wrong deny.
+ const targets = [];
+ let sawPattern = false;
+ for (; i < toks.length; i++) {
+ const { text, quoted } = toks[i];
+ // The operands end at a pipeline boundary or a comment: `| head -5` and
+ // `# note` are not paths this search reads.
+ if (!quoted && (text === PIPE || text.startsWith(COMMENT_START))) break;
+ if (!quoted && (text === "--" || text.startsWith("-"))) continue;
+ if (!quoted && REDIRECT_START.test(text)) {
+ // `rg foo > hits.txt` used to read `hits.txt` as a target that failed
+ // open. The destination is not a search target, so it is dropped; an
+ // input redirection (`grep foo < notes.log`) means the search reads a
+ // stream whose coverage the index cannot speak to, so it is allowed.
+ if (text.includes("<")) return undefined;
+ if (OUT_REDIRECT.test(text)) i++; // operator and operand are separate tokens
+ continue;
+ }
+ if (!sawPattern) {
+ sawPattern = true;
+ continue;
+ }
+ // An unquoted trailing `)` / `}` is group syntax (`( rg auth src )`,
+ // `{ rg auth src; }`), not part of a filename - a real parenthesised name
+ // arrives quoted. Left in place it made an uncovered "target" out of thin
+ // air and flipped the decision open.
+ const target = quoted ? text : text.replace(/[)}]+$/, "");
+ if (target === "") continue;
+ targets.push(target);
+ }
+ return { fallback, targets, isSearch: sawPattern, chdir };
+}
+
+const decision = (permissionDecision, permissionDecisionReason) =>
+ console.log(
+ JSON.stringify({
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason },
+ }),
+ );
+
+const DENY_REASON =
+ "code-context: the index fully covers this repo, so grep/rg for code search is disabled. " +
+ "Use the sql MCP tool - hybrid_search('chunks','content','','embedding', {{q}}, k) for " +
+ "ranked retrieval, GROUP BY over it for counts/rankings. Piping other command output through " +
+ "grep is still allowed. If an index search genuinely came up short, re-run this command " +
+ "prefixed with CX_GREP_FALLBACK=1 to request it; CX_NO_ENFORCE=1 in the environment disables " +
+ "enforcement entirely.";
+
+const ASK_REASON =
+ "code-context fallback: the agent signals an index search came up short and asks to grep " +
+ "directly. Allow this grep?";
+
+let data = "";
+process.stdin.on("data", (c) => (data += c));
+process.stdin.on("end", () => {
+ let input = {};
+ try {
+ input = JSON.parse(data);
+ } catch {
+ return; // unparseable payload: do nothing rather than break the call
+ }
+
+ // SessionStart only describes the index; the note itself adapts to the kill
+ // switch, and every enforcing branch sits below it.
+ if (input.hook_event_name === "SessionStart") {
+ const note =
+ process.env.CX_NO_ENFORCE === "1"
+ ? "code-context is available: search the code with the sql MCP tool via table-valued functions - hybrid_search('chunks','content','','embedding', {{q}}, k) for ranked retrieval, bm25_search for keyword-only, GROUP BY for counts/rankings."
+ : indexInfo(input.cwd).state === "ready"
+ ? "code-context is active and the index fully covers this repo: all code search goes through the sql MCP tool via table-valued functions - hybrid_search('chunks','content','','embedding', {{q}}, k) for ranked retrieval (embed map {\"q\":...}), bm25_search for keyword-only, GROUP BY over either for counts/rankings. The Grep tool and standalone grep/rg commands are disabled (grep as a pipe filter on other command output still works; if an index search genuinely came up short, prefix the grep with CX_GREP_FALLBACK=1 to request it)."
+ : "code-context is available: search the code with the sql MCP tool via table-valued functions - hybrid_search('chunks','content','','embedding', {{q}}, k) for ranked retrieval, bm25_search for keyword-only, GROUP BY for counts/rankings (the index builds as the server starts). grep stays enabled until the index fully covers the repo.";
+ console.log(
+ JSON.stringify({
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: note },
+ }),
+ );
+ return;
+ }
+
+ // The kill switch, above every enforcement branch - including the legacy
+ // `search` deny, which used to run in front of it.
+ if (process.env.CX_NO_ENFORCE === "1") return;
+
+ const toolName = input.tool_name ?? "";
+
+ // Legacy `search` tool (pre-0.2 servers): sql is the search surface.
+ if (/code[-_]context.*__search$/.test(toolName)) {
+ decision(
+ "deny",
+ 'code-context: search goes through the sql tool. Ranked retrieval: SELECT path, start_line, end_line, symbol, content FROM hybrid_search(\'chunks\',\'content\',\'\',\'embedding\', {{q}}, 10) with embed {"q":""} - or bm25_search(\'chunks\',\'content\',\'\', 10) while vectors are backfilling. Rank + aggregate composes via GROUP BY.',
+ );
+ return;
+ }
+
+ if (toolName === "Grep") {
+ const info = indexInfo(input.cwd);
+ if (info.state !== "ready") return;
+ const keys = indexedFiles(info.indexDir);
+ if (!keys) return;
+ const path = input.tool_input?.path;
+ const glob = input.tool_input?.glob;
+ if (path && !coversTarget(path, input.cwd, info.root, keys)) return;
+ if (glob) {
+ // The tool's glob is relative to `path` when one is given.
+ const base = path
+ ? isAbsolute(path)
+ ? path
+ : resolve(input.cwd || info.root, path)
+ : input.cwd || info.root;
+ if (!coversGlob(glob, base, info.root, keys, true)) return;
+ }
+ decision("deny", DENY_REASON);
+ return;
+ }
+
+ if (toolName === "Bash") {
+ const command = input.tool_input?.command ?? "";
+ const launches = segments(stripHeredocs(command))
+ .map(grepLaunch)
+ .filter((l) => l !== undefined && l.isSearch);
+ if (launches.length === 0) return;
+ const info = indexInfo(input.cwd);
+ if (info.state !== "ready") return;
+ const keys = indexedFiles(info.indexDir);
+ if (!keys) return;
+ for (const launch of launches) {
+ // `git -C dir` moves the directory relative targets resolve against, and
+ // with no target of its own it IS the scope of the search - so `git -C .
+ // grep auth` reads as this repo and `git -C ../other grep auth` as a tree
+ // the index does not speak for. Any other launch with no explicit target
+ // is a repo-wide search, which the index covers.
+ const chdir = launch.chdir
+ ? isAbsolute(launch.chdir)
+ ? launch.chdir
+ : resolve(input.cwd || info.root, launch.chdir)
+ : undefined;
+ const targets = launch.targets.length > 0 ? launch.targets : chdir ? [chdir] : [];
+ if (!targets.every((t) => coversTarget(t, chdir ?? input.cwd, info.root, keys))) continue;
+ if (launch.fallback) decision("ask", ASK_REASON);
+ else decision("deny", DENY_REASON);
+ return;
+ }
+ }
+});
diff --git a/hooks/hooks.json b/hooks/hooks.json
new file mode 100644
index 0000000..4ef2c61
--- /dev/null
+++ b/hooks/hooks.json
@@ -0,0 +1,25 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-grep.mjs\""
+ }
+ ]
+ }
+ ],
+ "PreToolUse": [
+ {
+ "matcher": "Grep|Bash|mcp__.*code[-_]context.*__search",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-grep.mjs\""
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/llms.txt b/llms.txt
index 8aef780..ea1aa47 100644
--- a/llms.txt
+++ b/llms.txt
@@ -1,12 +1,13 @@
# code-context
> Local code search for AI coding agents: a CLI and an MCP server over an
-> index that lives in plain files inside the repo. Keyword (BM25) search is
-> live seconds after indexing starts, semantic and hybrid search unlock as
-> vectors backfill, and SQL composes ranked search with GROUP BY so "which
-> files have the most code about X" is one query. No accounts, no API keys,
-> no database server, no telemetry; embedding is a small local model and
-> code never leaves the machine.
+> index that lives in plain files inside the repo, queried through one door -
+> read-only SQL with ranked search table functions. hybrid_search fuses
+> keyword (BM25) and semantic ranking in one pass (bm25_search covers the
+> window before vectors backfill), and the same relation composes with
+> GROUP BY so "which files have the most code about X" is one query. No
+> accounts, no API keys, no database server, no telemetry; embedding is a
+> small local model and code never leaves the machine.
code-context is built on infino (https://github.com/infino-ai/infino), an
embedded retrieval engine; the same engine and index format also serve
@@ -25,20 +26,38 @@ logs, docs, and agent memory.
- Zero-install for Claude Code (recommended; `alwaysLoad` keeps the tools in
view when many MCP servers are configured):
`claude mcp add-json code-context -s user '{"command":"npx","args":["-y","@infino-ai/code-context","mcp"],"alwaysLoad":true}'`.
- A Claude Code plugin (`/plugin marketplace add infino-ai/code-context`) bakes the same config in.
-- MCP tools (stdio): `search` (exact terms AND meaning, one ranked pass,
- hits carry chunk content with path:line ranges), `sql` (read-only
- SELECT/WITH over `chunks(path, start_line, end_line, lang, content)`,
- ranked search functions (bm25_search/hybrid_search) usable as table-valued
- relations so search composes with GROUP BY), `reindex` (incremental sync;
- the server also auto-syncs in the
- background). A `search`/`sql` on a never-indexed repo builds the index
- inline and answers on the same call (keyword live in seconds, vectors
- backfilling); `CX_AUTO_INDEX=0` restores a strict "index it first" error.
- Each tool takes an optional `path` (absolute repo root) so one server serves
- multiple repos in a session; omit it for the startup root.
-- CLI: `cx index` (incremental; `--full`, `--watch`), `cx search`,
- `cx sql`, `cx status`, `cx mcp`.
+ A Claude Code plugin (`/plugin marketplace add infino-ai/code-context`) bakes the same config in
+ and ships the enforcement hooks below.
+- Enforcement (make the index the way an agent searches): the Claude Code
+ plugin ships the hooks; on every other client - `npx`, `claude mcp
+ add-json`, Cursor, Windsurf - `cx install` writes them, and it is the only
+ form there that survives a client restart. It copies
+ `~/.claude/hooks/cx-deny-grep.mjs` and merges SessionStart + PreToolUse
+ entries into `~/.claude/settings.json`; idempotent, reversed by
+ `cx install --uninstall`, retargeted by `--settings `, and it embeds
+ the absolute node path it ran with because a client process often has no
+ node on PATH and a hook that cannot find node fails silently. Once a repo's
+ index fully covers it (vectors ready, nothing over the file cap) the Grep
+ tool and standalone grep/rg/git grep are denied with a redirect to the sql
+ search functions; grep as a pipe filter on other output always passes, a
+ target the index cannot cover (gitignored, over the byte cap, a dot-path,
+ outside the repo) passes silently, `CX_GREP_FALLBACK=1 ` turns the
+ deny into an approval prompt, and `CX_NO_ENFORCE=1` disables enforcement.
+ Until the index is fully built grep is untouched. Use the plugin or
+ `cx install`, not both.
+- MCP tools (stdio): `sql` (the search surface: read-only SELECT/WITH over
+ `chunks(path, start_line, end_line, lang, symbol, content[, embedding])`,
+ with hybrid_search/bm25_search/vector_search as table-valued relations -
+ ranked retrieval and GROUP BY aggregation in one query) and `reindex`
+ (incremental sync; the server also auto-syncs in
+ the background). The server builds the index eagerly at startup, and a
+ query that beats the build still triggers it inline; `CX_AUTO_INDEX=0`
+ restores a strict "index it first" error. Each tool takes an optional
+ `path` (absolute repo root) so one server serves multiple repos in a
+ session; omit it for the startup root.
+- CLI: `cx index` (incremental; `--full`, `--watch`), `cx sql`,
+ `cx status`, `cx usage`, `cx mcp`, `cx install` (`--uninstall`,
+ `--settings `).
## Evidence
diff --git a/package.json b/package.json
index b5fc75b..3ce716d 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,8 @@
},
"main": "dist/cli.js",
"files": [
- "dist"
+ "dist",
+ "hooks"
],
"engines": {
"node": ">=20"
@@ -46,6 +47,7 @@
},
"scripts": {
"build": "tsc",
+ "prepare": "npm run build",
"watch": "tsc --watch",
"test": "vitest run",
"prepack": "npm run build"
diff --git a/skills/code-context/SKILL.md b/skills/code-context/SKILL.md
index d8da38c..22f5d1b 100644
--- a/skills/code-context/SKILL.md
+++ b/skills/code-context/SKILL.md
@@ -1,68 +1,72 @@
---
name: code-context
description: >
- How to answer codebase questions with the code-context MCP tools (search,
- sql, reindex): ranked hybrid keyword+semantic search, relevance-ranked SQL
- aggregation over the index, and index lifecycle. Use when a question spans
- many files ("how does X work", "where is Y handled"), when ranking or
- counting code by topic across a repo, or when the code-context tools are
- present but deferred and need loading before use. Not needed for jumping
- to one known identifier - plain grep is fine there.
+ How to answer codebase questions with the code-context MCP tools (sql,
+ reindex): ranked retrieval and relevance-ranked aggregation in one
+ read-only SQL statement via search table functions, plus index lifecycle.
+ Use when a question spans many files ("how does X work", "where is Y
+ handled"), when ranking or counting code by topic across a repo, or when
+ the code-context tools are present but deferred and need loading before
+ use.
---
-# code-context: ranked search over the repository
+# code-context: ranked search over the repository, through SQL
code-context maintains a local index of the repository (in `.infino/` at the
-repo root) and exposes three MCP tools. The more a question spans the repo,
-the more one ranked pass beats crawling files into context.
+repo root) and exposes two MCP tools: `sql` and `reindex`. All retrieval goes
+through `sql` — ranked search is a table-valued function inside the query, so
+finding, counting, and ranking code are all one SELECT. The more a question
+spans the repo, the more one ranked query beats crawling files into context.
## If the tools are deferred
When the tool names appear in a deferred-tools listing but their schemas are
-not loaded, load all three in ONE ToolSearch call before the first use, e.g.
-query `+code-context search sql reindex` (or `select:` with the exact
-listed names, comma-separated). Never load them one call at a time.
+not loaded, load both in ONE ToolSearch call before the first use, e.g.
+query `+code-context sql reindex` (or `select:` with the exact listed names,
+comma-separated). Never load them one call at a time.
-## Choosing the right tool
+## Choosing the right query
| Situation | Use |
| --- | --- |
-| One known identifier, literal string, or file | plain grep / file tools |
-| "How does X work", "where is Y handled", concept without exact name | `search` |
-| Counts, rankings, GROUP BY across the repo ("which files have the most code about X") | `sql` |
+| "How does X work", "where is Y handled", concept without exact name | `hybrid_search` in FROM |
+| One known identifier or literal string | `bm25_search` in FROM |
+| Counts, rankings, GROUP BY across the repo ("which files have the most code about X") | either TVF + `GROUP BY` |
| Working tree changed a lot mid-session | `reindex` (usually unnecessary - see lifecycle) |
-## search
-
-- Pass terms, a phrase, or a plain-language description; one pass fuses BM25
- keyword matching with semantic similarity, so it works whether or not you
- know the exact words.
-- One good search beats several narrow ones - put both the identifiers you
- know and the intent into a single query.
-- Every hit carries `path`, `startLine`-`endLine`, and the chunk content with
- a relevance score. Answer from the chunk content when it suffices, citing
- the `path:line` ranges; open a file only for what the chunks don't show.
-- If a hit is marked `truncated`, Read exactly its start-end range
- (offset/limit), not the whole file.
-- `k` (default 10, max 50) bounds hits; raise it for survey-style questions.
-- Until the index's vector stage finishes, results say they are
- keyword-ranked; they are still real, cited hits.
-
## sql
One read-only SELECT/WITH statement over the table
`chunks(path, start_line, end_line, lang, symbol, content[, embedding])`.
-Search functions are callable as table-valued relations, so one query can
-rank AND aggregate:
-
-- `bm25_search('chunks','content','', k)` - keyword ranking, no
- embedding needed.
-- `hybrid_search('chunks','content','','embedding', {{q}}, k)` and
- `vector_search('chunks','embedding', {{q}}, k)` - take a `{{name}}`
- placeholder filled via the `embed` argument, e.g. `{"q": "query text"}`.
+Search functions are table-valued relations, so ranking happens in the FROM
+clause and everything above it is ordinary SQL.
+
+Finding code is a query:
+
+```sql
+SELECT path, start_line, end_line, symbol, content
+FROM hybrid_search('chunks','content','','embedding', {{q}}, 10)
+```
+
+with the `embed` argument filling `{{q}}`, e.g. `{"q": "where is auth
+handled"}`. The functions:
+
+- `hybrid_search('chunks','content','','embedding', {{q}}, k)` - one
+ ranked pass fusing BM25 keyword matching with semantic similarity; works
+ whether or not you know the exact words. The default choice.
+- `bm25_search('chunks','content','', k)` - keyword ranking alone, no
+ embedding needed; use it for exact identifiers and while vectors are still
+ backfilling.
+- `vector_search('chunks','embedding', {{q}}, k)` - semantic ranking alone;
+ prefer `hybrid_search`, which keeps the keyword arm too.
- `regexp_like(content, 'pattern')` works in WHERE.
-The canonical move - "which files have the most code about X":
+One good query beats several narrow ones - put both the identifiers you know
+and the intent into a single terms string. `k` bounds the ranked rows; raise
+it for survey-style questions. Never scan the table with `LIKE` when a TVF
+can rank.
+
+The canonical aggregation - "which files have the most code about X":
```sql
SELECT path, SUM(end_line - start_line + 1) AS lines, COUNT(*) AS chunks
@@ -70,11 +74,18 @@ FROM bm25_search('chunks','content','', 300)
GROUP BY path ORDER BY lines DESC LIMIT 15
```
+Answer from the returned chunk content when it suffices, citing the
+`path:start_line-end_line` ranges; open a file only for what the chunks
+don't show, and Read exactly that range (offset/limit), not the whole file.
+
## Index lifecycle (usually zero-touch)
-- **First query in a never-indexed repo auto-builds the index** and answers
- on the same call: it returns as soon as keyword search is live (seconds),
- while vectors backfill in the background. Do not pre-emptively reindex.
+- **The index builds as the server starts**, so it is typically live before
+ your first query; a query that beats the build still triggers it inline
+ and answers on the same call. Do not pre-emptively reindex.
+- A result noting that **vectors are still backfilling** means
+ `hybrid_search`/`vector_search` rank keyword-only/partial for the moment;
+ `bm25_search` is unaffected. The note disappears when vectors are ready.
- **Later queries auto-sync**: the server re-chunks only files that changed
since the last index. An unchanged tree is a fast no-op.
- Call `reindex` explicitly only after sweeping working-tree changes you
@@ -88,9 +99,9 @@ GROUP BY path ORDER BY lines DESC LIMIT 15
- A result carrying a `partial` marker means the repo exceeded the index's
file cap and some files were left out: treat a missing match as
possibly-unindexed, not as proof the code doesn't exist.
-- Search and sql results carry a one-line `usage` receipt (tokens returned,
- chunks/files, session running total), computed locally. End your reply by
- showing that line to the user verbatim.
+- Results carry a one-line `usage` receipt (tokens returned, rows, session
+ running total), computed locally. End your reply by showing that line to
+ the user verbatim.
## Multi-repo sessions
@@ -99,7 +110,7 @@ different repository than the one the server started in.
## Cost awareness
-- `search`/`sql` calls are cheap (local, milliseconds).
+- `sql` calls are cheap (local, milliseconds).
- The first index of a repo and the vector backfill are the expensive part
(CPU for the local embedding model, proportional to repo size). Avoid
forcing `full: true` rebuilds unless the index is actually wrong, and
diff --git a/src/cli.ts b/src/cli.ts
index e51eb45..87e3d61 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -4,32 +4,43 @@
//
// code-context / cx - local code search for AI coding agents.
+import { readFileSync } from "node:fs";
import { Command } from "commander";
import { indexCmd } from "./commands/index-cmd.js";
-import { searchCmd, sqlCmd, statusCmd, usageCmd } from "./commands/query-cmds.js";
-import { DEFAULT_SEARCH_K } from "./core/config.js";
+import { installCmd } from "./commands/install-cmd.js";
+import { sqlCmd, statusCmd, usageCmd } from "./commands/query-cmds.js";
+
+/** The package manifest, one level up from this module both in source
+ * (`src/cli.ts`) and in the shipped build (`dist/cli.js`). Reading the version
+ * from it at runtime is what keeps `cx --version` from drifting away from what
+ * npm published, which a hand-maintained literal here did. */
+const PACKAGE_JSON = new URL("../package.json", import.meta.url);
+const { version } = JSON.parse(readFileSync(PACKAGE_JSON, "utf8")) as { version: string };
const program = new Command();
program
.name("cx")
.description(
- "Local code search for AI coding agents - an index in plain files under .infino/.\n" +
- "Keyword search seconds after `cx index`; semantic and hybrid search when vectors\n" +
- "finish backfilling; SQL with relevance-ranked aggregation over the whole repo.",
+ "Local code search for AI coding agents - an index in plain files under .infino/,\n" +
+ "queried through one door: read-only SQL with ranked search table functions.\n" +
+ "hybrid_search fuses keyword + semantic ranking, bm25_search covers the window\n" +
+ "before vectors finish backfilling, and GROUP BY turns either into aggregation.",
)
- .version("0.1.4")
+ .version(version)
.addHelpText(
"after",
`
Examples:
cx index index the current repo (keyword search is live in seconds)
- cx search "parse_config" exact terms and meaning, one ranked pass
- cx search "where is auth handled" works when you don't know the words
+ cx sql "SELECT path, start_line, end_line, symbol, content \\
+ FROM hybrid_search('chunks','content','auth handling','embedding', {{q}}, 10)" \\
+ --embed "q=where is auth handled"
cx sql "SELECT path, SUM(end_line - start_line + 1) AS lines \\
FROM bm25_search('chunks','content','vector index', 300) \\
GROUP BY path ORDER BY lines DESC LIMIT 10"
- cx mcp serve the MCP tools (search/sql/reindex) over stdio`,
+ cx mcp serve the MCP tools (sql/reindex) over stdio
+ cx install make the index the way Claude Code searches (hooks; --uninstall reverses)`,
);
program
@@ -43,15 +54,6 @@ program
.option("--json", "machine-readable stats")
.action(indexCmd);
-program
- .command("search")
- .description("find code: exact terms and meaning in one ranked pass")
- .argument("", "what you're looking for")
- .option("-k ", "maximum hits", String(DEFAULT_SEARCH_K))
- .option("--json", "machine-readable output")
- .option("-C, --path ", "repo root (default: current directory)")
- .action(searchCmd);
-
program
.command("sql")
.description("read-only SQL over the index, including ranked search table functions")
@@ -66,6 +68,17 @@ program
.option("-C, --path ", "repo root (default: current directory)")
.action(sqlCmd);
+program
+ .command("install")
+ .description(
+ "wire index-first enforcement into Claude Code (hooks that steer code search to sql) - " +
+ "Claude Code only; other MCP clients have no hook surface to configure",
+ )
+ .option("--uninstall", "remove the hooks this command installed")
+ .option("--settings ", "settings file to edit (default: ~/.claude/settings.json)")
+ .option("--force", "allow a project-scoped .claude settings file (its paths are machine-specific)")
+ .action(installCmd);
+
program
.command("status")
.description("show what the index holds and how fresh it is")
@@ -87,7 +100,7 @@ program
program
.command("mcp")
- .description("serve the MCP tools (search / sql / reindex) over stdio")
+ .description("serve the MCP tools (sql / reindex) over stdio")
.option("-C, --path ", "repo root (default: current directory)")
.action(async (opts: { path?: string }) => {
const { serveMcp } = await import("./mcp/server.js");
diff --git a/src/commands/install-cmd.ts b/src/commands/install-cmd.ts
new file mode 100644
index 0000000..f7b27d1
--- /dev/null
+++ b/src/commands/install-cmd.ts
@@ -0,0 +1,491 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright The Infino Authors
+//
+// `cx install` - wire index-first enforcement into Claude Code, for users who
+// reach code-context as a plain MCP server (`npx`, `claude mcp add-json`)
+// rather than through the Claude Code plugin. The plugin ships the same hooks
+// in its own `hooks/` directory; this command is the path for a hand-wired
+// server, and it is the only path that survives a client restart without
+// per-session setup.
+//
+// Claude Code only. The hook schema written here and `~/.claude/settings.json`
+// are Claude Code's; other MCP clients (Cursor, Windsurf, ...) run the same
+// `sql` and `reindex` tools but expose no hook surface, so there is nothing
+// for this command to configure there and it does not pretend otherwise.
+//
+// What it writes, all idempotent and reversible with --uninstall:
+// ~/.claude/hooks/cx-deny-grep.mjs the hook itself, copied from the package
+// ~/.claude/settings.json SessionStart + PreToolUse entries
+//
+// The hook command embeds `process.execPath` - the absolute node that is
+// running this install - because the client's process often has no node on
+// PATH (a hand-wired MCP entry with an absolute node path is the common case),
+// and a hook that cannot find node fails silently, which reads exactly like
+// "enforcement isn't working". The same reasoning makes every path absolute
+// and makes an unresolvable home directory an error: a relative settings or
+// script path resolves against whatever directory the client happens to be
+// in, so it would be written successfully and never run.
+//
+// The settings file belongs to the user - their other hooks, their unrelated
+// keys - so three rules hold everywhere below: ownership is decided per hook
+// *command* (the resolved script path we copied in) and never per entry; the
+// rewrite is a temp file plus rename beside the *real* file the path resolves
+// to, so a crash cannot truncate it and a dotfile symlink survives it; and
+// anything we cannot parse is refused rather than overwritten.
+
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ readlinkSync,
+ realpathSync,
+ renameSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
+import { basename, dirname, isAbsolute, join, resolve } from "node:path";
+import { homedir } from "node:os";
+import { fileURLToPath } from "node:url";
+import { bold, dim, green, yellow } from "../core/output.js";
+
+/** Name the packaged hook is copied to, inside the settings directory. */
+const HOOK_BASENAME = "cx-deny-grep.mjs";
+
+/** Subdirectory of the settings directory that holds hook scripts. */
+const HOOK_DIR = "hooks";
+
+/** Claude Code's settings directory and its default settings file. */
+const CLAUDE_DIR = ".claude";
+const SETTINGS_BASENAME = "settings.json";
+
+/** Tool names the PreToolUse hook inspects: the built-in search tools plus a
+ * legacy code-context `search` tool from a pre-2.0 server. */
+const PRE_TOOL_MATCHER = "Grep|Bash|mcp__.*code[-_]context.*__search";
+
+/** The hook events this command owns. Every other event in the file, and
+ * every hook in these two that is not ours, is left exactly as found. */
+const OUR_EVENTS = ["SessionStart", "PreToolUse"] as const;
+
+/** Files beside the target a live hook command can hide in. Claude Code loads
+ * `settings.json` and `settings.local.json` from one directory and `--settings`
+ * takes any name at all, so the sibling scan reads every JSON file there
+ * rather than guessing at names: they share one hooks/ directory, and the
+ * script we would delete is the one the survivor runs. */
+const JSON_FILE = /\.json$/i;
+
+/** Trailing argument of a hook command, quoted or bare - where our entries
+ * carry the script path. */
+const QUOTED_TAIL = /"([^"]*)"\s*$/;
+const BARE_TAIL = /(\S+)\s*$/;
+
+/** Indent for the settings file we write back, matching Claude Code's own. */
+const SETTINGS_INDENT = 2;
+
+/** Suffix of the temp file the atomic settings replace goes through. */
+const TMP_SUFFIX = ".cx-tmp";
+
+export interface InstallCmdOptions {
+ /** Remove the hook and its settings entries instead of installing. */
+ uninstall?: boolean;
+ /** Target a settings file other than ~/.claude/settings.json. */
+ settings?: string;
+ /** Install into a project-scoped `.claude` directory anyway - only sensible
+ * for a settings file that stays on this machine (a gitignored
+ * settings.local.json); see the refusal in `installCmd`. */
+ force?: boolean;
+}
+
+interface HookCommand {
+ type: string;
+ command: string;
+}
+
+interface HookEntry {
+ matcher?: string;
+ hooks: HookCommand[];
+}
+
+type Settings = Record & { hooks?: unknown };
+
+/** A failure the user can act on. The CLI layer prints `error: ` and
+ * sets the exit code; nothing in here calls `process.exit`, so every branch
+ * stays reachable from a test. */
+export class InstallError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "InstallError";
+ }
+}
+
+/** `null`, `an array`, `a string`: enough for the user to see what they have. */
+function describeJson(value: unknown): string {
+ if (value === null) return "null";
+ if (Array.isArray(value)) return "an array";
+ return `a ${typeof value}`;
+}
+
+/** The user's home directory, as an absolute path. `os.homedir()` answers ""
+ * under `env -i`, systemd units, and some CI runners; joining that yields a
+ * relative settings path that lands in the current working directory and a
+ * relative script path in the hook command. Both look like success and
+ * neither works, so this fails loudly instead. */
+function resolveHome(): string {
+ const home = homedir();
+ if (!isAbsolute(home)) {
+ throw new InstallError(
+ `cannot resolve your home directory - os.homedir() returned ${JSON.stringify(home)}. ` +
+ `Set HOME to an absolute path, e.g. HOME=/home/you cx install, and re-run.`,
+ );
+ }
+ return home;
+}
+
+/** Parse the settings file, or `{}` when there is none. A file we cannot read
+ * as a JSON object is the user's to fix: Claude Code settings are plain JSON
+ * (no comments, no trailing commas), and overwriting a file we did not
+ * understand would cost them every setting in it, not just our hooks. */
+function readSettings(settingsPath: string): Settings {
+ if (!existsSync(settingsPath)) return {};
+ const text = readFileSync(settingsPath, "utf8");
+ if (text.trim() === "") return {};
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch (err) {
+ throw new InstallError(
+ `${settingsPath} is not valid JSON: ${(err as Error).message}. Claude Code settings are plain ` +
+ `JSON - no comments, no trailing commas. Fix the file (or move it aside) and re-run.`,
+ );
+ }
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new InstallError(
+ `${settingsPath} holds ${describeJson(parsed)}, not a JSON object of settings. ` +
+ `Fix the file (or move it aside) and re-run.`,
+ );
+ }
+ return parsed as Settings;
+}
+
+/** The `hooks` block, validated far enough that our edits cannot fail halfway
+ * through: the block must be an object, and each event we touch must be an
+ * array of entries. Events we do not touch pass through unread. */
+function readHooks(settings: Settings, settingsPath: string): Record {
+ const raw = settings.hooks;
+ if (raw === undefined) return {};
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
+ throw new InstallError(
+ `"hooks" in ${settingsPath} is ${describeJson(raw)}, not an object of hook events. ` +
+ `Fix the file and re-run.`,
+ );
+ }
+ const hooks = raw as Record;
+ for (const event of OUR_EVENTS) {
+ const entries: unknown = hooks[event];
+ if (entries !== undefined && !Array.isArray(entries)) {
+ throw new InstallError(
+ `"hooks.${event}" in ${settingsPath} is ${describeJson(entries)}, not an array of hook ` +
+ `entries. Fix the file and re-run.`,
+ );
+ }
+ }
+ return hooks;
+}
+
+/** The trailing argument of a hook command, without its quotes. */
+function trailingArg(command: string): string {
+ const quoted = QUOTED_TAIL.exec(command);
+ if (quoted) return quoted[1];
+ const bare = BARE_TAIL.exec(command);
+ return bare ? bare[1] : "";
+}
+
+/** Does this hook run the script we copied in? Keyed on the resolved path we
+ * wrote, not on the basename: a wrapper of the user's own that merely names
+ * `cx-deny-grep.mjs` on its command line is not ours to remove. */
+function runsOurHook(hook: unknown, hookPath: string): boolean {
+ const command = (hook as HookCommand | null)?.command;
+ return typeof command === "string" && trailingArg(command) === hookPath;
+}
+
+/** One event's entries with our hook taken out. The filter runs at the inner
+ * level: an entry the user shares with us keeps its matcher and its own
+ * hooks, and only disappears when ours was the last hook in it. Entries that
+ * are not the shape we write pass through untouched. */
+function withoutOurHook(entries: HookEntry[], hookPath: string): HookEntry[] {
+ const kept: HookEntry[] = [];
+ for (const entry of entries) {
+ const inner = (entry as { hooks?: unknown } | null)?.hooks;
+ if (!Array.isArray(inner)) {
+ kept.push(entry);
+ continue;
+ }
+ const others = inner.filter((hook) => !runsOurHook(hook, hookPath));
+ if (others.length === inner.length) kept.push(entry);
+ else if (others.length > 0) kept.push({ ...entry, hooks: others });
+ }
+ return kept;
+}
+
+/** Does this settings text still run `hookPath`? A file we cannot parse counts
+ * as a reference when it names our script at all: keeping a hook file nothing
+ * runs is harmless, deleting one that is still wired up breaks every tool call
+ * in that session with MODULE_NOT_FOUND. */
+function referencesHook(text: string, hookPath: string): boolean {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ return text.includes(HOOK_BASENAME);
+ }
+ const hooks = (parsed as Settings | null)?.hooks;
+ if (hooks === null || typeof hooks !== "object") return false;
+ return Object.values(hooks as Record).some(
+ (entries) =>
+ Array.isArray(entries) &&
+ entries.some((entry) => {
+ const inner = (entry as { hooks?: unknown } | null)?.hooks;
+ return Array.isArray(inner) && inner.some((hook) => runsOurHook(hook, hookPath));
+ }),
+ );
+}
+
+/** Settings files beside `settingsPath` that still run `hookPath`. Claude Code
+ * loads settings.json and settings.local.json from the same directory and the
+ * hook path we write is identical for both, so uninstalling one must not
+ * delete the script the other still points at. The file we are uninstalling is
+ * skipped by real path, so a link to it does not count as a second user. */
+function siblingsUsingHook(settingsDir: string, settingsPath: string, hookPath: string): string[] {
+ let names: string[];
+ try {
+ names = readdirSync(settingsDir);
+ } catch {
+ return [];
+ }
+ const self = realTarget(settingsPath);
+ const found: string[] = [];
+ for (const name of names) {
+ const candidate = join(settingsDir, name);
+ if (!JSON_FILE.test(name) || realTarget(candidate) === self) continue;
+ let text: string;
+ try {
+ text = readFileSync(candidate, "utf8");
+ } catch {
+ continue;
+ }
+ if (referencesHook(text, hookPath)) found.push(candidate);
+ }
+ return found;
+}
+
+/** The file a path really names: the link target when it is a symlink, the
+ * path itself when it is not or does not exist yet. Dotfile managers make
+ * `~/.claude/settings.json` a symlink into a version-controlled directory, so
+ * every step that replaces or identifies the file has to follow the link -
+ * writing a new file at the link's own path would leave the tracked copy
+ * holding stale bytes, with the user's settings silently no longer applying. */
+function realTarget(path: string): string {
+ try {
+ return realpathSync(path);
+ } catch {
+ // realpath fails on a link whose target does not exist yet - a dotfiles
+ // checkout that has not happened. Follow the link by hand so the write
+ // creates the tracked file instead of replacing the link with a copy.
+ try {
+ return resolve(dirname(path), readlinkSync(path));
+ } catch {
+ // Not a link, or nothing there at all: the path is its own target.
+ return path;
+ }
+ }
+}
+
+/** Replace the settings file in one filesystem step. A plain write truncates
+ * first, so a crash - or a client reading while we write - can leave an empty
+ * settings.json, losing every setting the user has. The rename lands on the
+ * real file behind any symlink, and the temp file sits in that file's own
+ * directory so the rename cannot cross a filesystem boundary. */
+function writeSettings(settingsPath: string, settings: Settings): void {
+ const target = realTarget(settingsPath);
+ const dir = dirname(target);
+ mkdirSync(dir, { recursive: true });
+ const json = JSON.stringify(settings, null, SETTINGS_INDENT) + "\n";
+ // A hardlinked settings file must be written in place: the rename swaps in a
+ // new inode, quietly unlinking the file from its other name, so a dotfiles
+ // copy keeps the old bytes forever. realpath cannot see a hardlink, so this
+ // is decided on the link count, trading the atomic replace for keeping both
+ // names one file.
+ let links = 0;
+ try {
+ links = statSync(target).nlink;
+ } catch {
+ // No file yet - the rename path below creates it.
+ }
+ if (links > 1) {
+ writeFileSync(target, json);
+ return;
+ }
+ const tmp = join(dir, `${basename(target)}.${process.pid}${TMP_SUFFIX}`);
+ try {
+ writeFileSync(tmp, json);
+ renameSync(tmp, target);
+ } catch (err) {
+ rmSync(tmp, { force: true });
+ throw err;
+ }
+}
+
+/** Does this settings file belong to a project checkout rather than the user?
+ * A `.claude` directory anywhere but the home one is a repo's: that file is
+ * shareable and usually version-controlled. The home lookup runs only for a
+ * `.claude` target, so any other --settings path stays usable without one.
+ * The comparison is on real paths: a relocated or symlinked home (HOME as a
+ * link, macOS /tmp -> /private/tmp) spells the same directory two ways, and
+ * refusing the user's own settings file over spelling is a false alarm. */
+function isProjectScoped(settingsDir: string): boolean {
+ if (basename(settingsDir) !== CLAUDE_DIR) return false;
+ const homeClaude = join(resolveHome(), CLAUDE_DIR);
+ if (settingsDir === homeClaude) return false;
+ return realTarget(settingsDir) !== realTarget(homeClaude);
+}
+
+/** The packaged hook source: `/hooks/deny-grep.mjs`, resolved
+ * from this module's location so it works from a global install, an npx
+ * cache, or a local checkout alike. */
+function packagedHook(): string {
+ // dist/commands/install-cmd.js -> package root is two levels up.
+ const here = dirname(fileURLToPath(import.meta.url));
+ return resolve(here, "..", "..", HOOK_DIR, "deny-grep.mjs");
+}
+
+/** Remove our hooks from one settings file, then the script - but only once no
+ * sibling settings file in that directory still runs it. */
+function uninstall(settingsPath: string, settingsDir: string, hookPath: string): void {
+ if (!existsSync(settingsPath)) {
+ // Creating a settings file (and a .claude directory) to prove it holds
+ // none of our hooks would be a strange thing for a removal to do.
+ console.log(`${dim("nothing to remove")} - ${settingsPath} does not exist`);
+ return;
+ }
+
+ const settings = readSettings(settingsPath);
+ const hooks = readHooks(settings, settingsPath);
+ const before = JSON.stringify(hooks);
+ for (const event of OUR_EVENTS) {
+ const rest = withoutOurHook(hooks[event] ?? [], hookPath);
+ if (rest.length > 0) hooks[event] = rest;
+ else delete hooks[event];
+ }
+ const changed = JSON.stringify(hooks) !== before;
+
+ if (changed) {
+ // Ours were the only entries: leave no empty "hooks" husk behind.
+ if (Object.keys(hooks).length > 0) settings.hooks = hooks;
+ else delete settings.hooks;
+ writeSettings(settingsPath, settings);
+ console.log(`${green("removed")} code-context enforcement from ${bold(settingsPath)}`);
+ console.log(dim(" start a new Claude Code session for the removal to take effect"));
+ } else {
+ console.log(`${dim("nothing to remove")} - no code-context hooks in ${settingsPath}`);
+ }
+
+ if (existsSync(hookPath)) {
+ const stillUsed = siblingsUsingHook(settingsDir, settingsPath, hookPath);
+ if (stillUsed.length === 0) rmSync(hookPath, { force: true });
+ else console.log(dim(` kept ${hookPath} - still run by ${stillUsed.join(", ")}`));
+ }
+}
+
+export function installCmd(opts: InstallCmdOptions): void {
+ const settingsPath = opts.settings
+ ? resolve(opts.settings)
+ : join(resolveHome(), CLAUDE_DIR, SETTINGS_BASENAME);
+ const settingsDir = dirname(settingsPath);
+ const hookPath = join(settingsDir, HOOK_DIR, HOOK_BASENAME);
+ // `resolve` and the home join both produce absolute paths; assert it before
+ // the path reaches a hook command, where a relative script would resolve
+ // against the client's working directory and quietly never run.
+ if (!isAbsolute(hookPath)) {
+ throw new InstallError(
+ `refusing to write the relative hook path ${hookPath} into a hook command - ` +
+ `pass an absolute path to --settings.`,
+ );
+ }
+
+ if (opts.uninstall) {
+ uninstall(settingsPath, settingsDir, hookPath);
+ return;
+ }
+
+ // Everything we write is specific to this machine, so a settings file meant
+ // to be shared is the one place it must not go. Uninstall stays allowed
+ // above, so a --force install can always be undone.
+ if (isProjectScoped(settingsDir) && !opts.force) {
+ throw new InstallError(
+ [
+ `${settingsPath} looks like a project-scoped Claude Code settings file, not your own.`,
+ `Its entries would carry absolute paths from this machine (${process.execPath}, plus a hook` +
+ ` file copied inside the project), so a teammate who checks the file out gets` +
+ ` MODULE_NOT_FOUND on every tool call instead of enforcement.`,
+ `To share enforcement with a team, use the code-context plugin for Claude Code - it resolves` +
+ ` the hook from the plugin root on each machine.`,
+ `To install it for yourself, run \`cx install\` with no --settings. Add --force only for a` +
+ ` settings file that stays on this machine, such as a gitignored settings.local.json.`,
+ ].join("\n"),
+ );
+ }
+
+ const source = packagedHook();
+ if (!existsSync(source)) {
+ throw new InstallError(
+ `packaged hook not found at ${source} - this copy of the package is missing its hooks/ ` +
+ `directory. Reinstall it (npm i -g @infino-ai/code-context) and re-run.`,
+ );
+ }
+ // Parse and validate before writing anything, so a settings file we refuse
+ // does not leave a freshly copied hook behind.
+ const settings = readSettings(settingsPath);
+ const hooks = readHooks(settings, settingsPath);
+ // A script already there was put there by an install that also wrote the
+ // settings entries running it, so it stays whatever happens below; one we
+ // copy in now is only ever reachable through the settings we are about to
+ // write, and has to go with them if that write fails.
+ const hookExisted = existsSync(hookPath);
+ mkdirSync(dirname(hookPath), { recursive: true });
+ copyFileSync(source, hookPath);
+
+ // Absolute node + absolute hook path: no PATH assumptions, no cwd assumptions.
+ const command = `"${process.execPath}" "${hookPath}"`;
+ hooks.SessionStart = [
+ ...withoutOurHook(hooks.SessionStart ?? [], hookPath),
+ { hooks: [{ type: "command", command }] },
+ ];
+ hooks.PreToolUse = [
+ ...withoutOurHook(hooks.PreToolUse ?? [], hookPath),
+ { matcher: PRE_TOOL_MATCHER, hooks: [{ type: "command", command }] },
+ ];
+ settings.hooks = hooks;
+ try {
+ writeSettings(settingsPath, settings);
+ } catch (err) {
+ if (!hookExisted) rmSync(hookPath, { force: true });
+ throw err;
+ }
+
+ console.log(`${bold("code-context")} enforcement installed for ${bold("Claude Code")}`);
+ console.log(` wrote ${settingsPath}`);
+ console.log(` hook ${hookPath}`);
+ console.log(` node ${process.execPath}`);
+ console.log("");
+ console.log("Once a repo's index fully covers it (vectors ready, nothing over the file cap),");
+ console.log("the Grep tool and standalone grep/rg are denied with a redirect to the sql tool.");
+ console.log(dim(" grep as a pipe filter on other output always passes"));
+ console.log(dim(" a target the index cannot cover (gitignored, oversized, outside the repo) passes"));
+ console.log(dim(" CX_GREP_FALLBACK=1 asks instead of denying; CX_NO_ENFORCE=1 disables"));
+ console.log(dim(" Claude Code only - Cursor, Windsurf and other MCP clients have no hook surface"));
+ console.log("");
+ console.log(yellow("start a new Claude Code session to load the hooks"));
+ console.log(dim(" reverse with: cx install --uninstall"));
+}
diff --git a/src/commands/query-cmds.ts b/src/commands/query-cmds.ts
index fe72707..b8ad333 100644
--- a/src/commands/query-cmds.ts
+++ b/src/commands/query-cmds.ts
@@ -1,15 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The Infino Authors
//
-// `cx search` / `cx sql` / `cx status` - the query commands.
+// `cx sql` / `cx status` / `cx usage` - the query commands.
import { openIndex, NoIndexError } from "../core/context.js";
import { indexDir, resolveRoot } from "../core/config.js";
import { createEmbedder, embedderInfo } from "../core/embedder.js";
-import { search, runSql, jsonify } from "../core/searcher.js";
+import { runSql, jsonify } from "../core/searcher.js";
import {
receiptEnabled,
- searchEntry,
sqlEntry,
formatReceipt,
recordUsage,
@@ -19,7 +18,7 @@ import {
recordHookEvent,
currentSessionStats,
} from "../core/usage.js";
-import { bold, dim, cyan, yellow, green, table, fmtAge, fmtCount, fmtMs } from "../core/output.js";
+import { bold, dim, cyan, yellow, table, fmtAge, fmtCount, fmtMs } from "../core/output.js";
function die(err: unknown): never {
const msg = err instanceof NoIndexError ? err.message : `error: ${(err as Error).message}`;
@@ -27,39 +26,6 @@ function die(err: unknown): never {
process.exit(1);
}
-export interface SearchCmdOptions {
- k: string;
- json?: boolean;
- path?: string;
-}
-
-export async function searchCmd(query: string, opts: SearchCmdOptions): Promise {
- try {
- const handle = openIndex(opts.path);
- const result = await search(handle, createEmbedder(), query, Number(opts.k));
- if (receiptEnabled()) {
- const entry = searchEntry(result, handle.root);
- recordUsage(handle.dir, entry);
- console.error(dim(formatReceipt(entry)));
- }
- if (opts.json) {
- console.log(jsonify(result, true));
- return;
- }
- if (result.note) console.error(yellow(`note: ${result.note}`));
- if (result.partial) console.error(yellow(`warning: ${result.partial.note}`));
- result.hits.forEach((h, i) => {
- console.log(
- `${bold(String(i + 1) + ".")} ${cyan(h.path)}${dim(`:${h.startLine}-${h.endLine}`)} ${dim(`(${result.ranking} ${h.score.toFixed(3)})`)}`,
- );
- console.log(` ${h.content.split("\n").slice(0, 5).join("\n ")}\n`);
- });
- if (result.hits.length === 0) console.error(yellow("no hits"));
- } catch (err) {
- die(err);
- }
-}
-
export interface SqlCmdOptions {
embed?: string[];
json?: boolean;
@@ -111,7 +77,7 @@ export function statusCmd(opts: StatusCmdOptions): void {
console.log(
`code-context index: ${fmtCount(m.chunks)} chunks from ${fmtCount(m.files)} files, ` +
`vectors ${m.vectors}, indexed ${fmtAge(m.indexedAt)}. ` +
- `MCP tools: search (terms + meaning), sql (aggregation), reindex (after big edits).`,
+ `MCP tools: sql (ranked search TVFs + aggregation), reindex (after big edits).`,
);
return;
}
@@ -187,7 +153,7 @@ export async function usageCmd(opts: UsageCmdOptions): Promise {
return;
}
if (entries.length === 0 && (!session || session.prompts === 0)) {
- console.error(yellow("no usage recorded yet - run `cx search`/`cx sql` here, or query via the MCP server"));
+ console.error(yellow("no usage recorded yet - run `cx sql` here, or query via the MCP server"));
return;
}
@@ -210,19 +176,9 @@ export async function usageCmd(opts: UsageCmdOptions): Promise {
const clock = new Date(e.ts).toLocaleTimeString("en-US", { hour12: false });
const tool = e.tool.padEnd(6);
const q = cyan(`"${truncate(e.query, 52)}"`);
- if (e.tool === "search") {
- const hits = e.hits ?? [];
- const files = new Set(hits.map((h) => h.path)).size;
- console.log(
- `${dim(clock)} ${bold(tool)} ${q} ${dim(`-> ${hits.length} hits / ${files} files | ~${fmtTokens(e.returnedTokens)} tok | ${e.ranking ?? "?"}`)}`,
- );
- const locs = hits.slice(0, 5).map((h) => `${h.path}:${h.startLine}-${h.endLine}`);
- if (locs.length) console.log(green(` ${locs.join(" ")}${hits.length > 5 ? dim(` (+${hits.length - 5} more)`) : ""}`));
- } else {
- console.log(
- `${dim(clock)} ${bold(tool)} ${q} ${dim(`-> ${e.rows ?? 0} rows | ~${fmtTokens(e.returnedTokens)} tok`)}`,
- );
- }
+ console.log(
+ `${dim(clock)} ${bold(tool)} ${q} ${dim(`-> ${e.rows ?? 0} rows | ~${fmtTokens(e.returnedTokens)} tok`)}`,
+ );
}
}
diff --git a/src/core/config.ts b/src/core/config.ts
index 810a4ab..d5e0cda 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -54,6 +54,3 @@ export const EMBED_BATCH = 32;
* files) without changing retrieval. */
export const EMBED_MAX_CHARS = Number(process.env.CX_EMBED_MAX_CHARS ?? 8000);
-/** Default number of search hits. Configurable per call (the `k` tool param /
- * CLI `-k`) and via CX_SEARCH_K for config/CI-level defaults. */
-export const DEFAULT_SEARCH_K = Number(process.env.CX_SEARCH_K ?? 10);
diff --git a/src/core/filestate.ts b/src/core/filestate.ts
index 8a0528e..4c86b52 100644
--- a/src/core/filestate.ts
+++ b/src/core/filestate.ts
@@ -17,6 +17,17 @@ export interface FileEntry {
size: number;
mtimeMs: number;
hash: string;
+ /** Rows this file contributed to the `chunks` table - `0` for a file that is
+ * fingerprinted but produced none (binary bytes behind an indexable
+ * extension, an empty `__init__.py`, a bare barrel `index.ts`). The state has
+ * to fingerprint those anyway, or every sync rediscovers them as "added";
+ * the count is what keeps the state from reading as a superset of the
+ * queryable table. The enforcement hook needs exactly that distinction: it
+ * may only deny grep on a path sql can answer for, so a file with no rows
+ * must not count as covered. Optional, and absent means "unknown" - state
+ * written before the field existed still reads, and the hook treats those
+ * entries as covered. */
+ chunks?: number;
}
export interface FileState {
@@ -57,7 +68,10 @@ export interface RepoDiff {
deleted: string[];
/** Candidate files whose size+mtime matched the stored entry (not hashed). */
unchanged: number;
- /** The next state to persist after the sync applies. */
+ /** The next state to persist after the sync applies. Entries carried over
+ * from the previous state keep their chunk count; `added`/`changed` entries
+ * are stamped by the caller, which is the only place the new count is
+ * knowable. */
next: FileState;
}
@@ -92,8 +106,12 @@ export function diffFiles(
if (buf === undefined) continue; // racing delete - drops out of the index
const hash = hashContent(buf);
if (before && before.hash === hash) {
- // touched but identical - refresh the stat fingerprint only
- next.files[c.path] = { size: c.size, mtimeMs: c.mtimeMs, hash };
+ // Touched but identical - refresh the stat fingerprint only, and carry
+ // the chunk count over: identical content chunks identically, and the
+ // caller never re-chunks this file to restamp it.
+ const refreshed: FileEntry = { size: c.size, mtimeMs: c.mtimeMs, hash };
+ if (before.chunks !== undefined) refreshed.chunks = before.chunks;
+ next.files[c.path] = refreshed;
unchanged++;
continue;
}
diff --git a/src/core/indexer.ts b/src/core/indexer.ts
index 986c284..ab67000 100644
--- a/src/core/indexer.ts
+++ b/src/core/indexer.ts
@@ -56,6 +56,7 @@ import {
hashContent,
readFileState,
writeFileState,
+ type FileEntry,
type FileState,
} from "./filestate.js";
import type { Embedder } from "./embedder.js";
@@ -177,14 +178,20 @@ export async function indexRepoStaged(opts: IndexOptions): Promise {
// while the indexed content doesn't. Reconcile the manifest when it drifts,
// otherwise the "index is partial" marker goes stale.
if (truncatedFiles !== (manifest.truncatedFiles ?? 0)) {
- const reconciled: Manifest = { ...manifest };
+ const reconciled: Manifest = { ...manifest, root };
if (truncatedFiles > 0) {
reconciled.truncatedFiles = truncatedFiles;
reconciled.maxFiles = caps.maxFiles;
@@ -663,8 +670,15 @@ export async function syncRepo(opts: IndexOptions): Promise {
delete diff.next.files[path];
continue;
}
+ // The diff stamped size/mtime/hash; the row count is only knowable
+ // here, and is recorded as 0 for a file that yields none (dropping the
+ // entry instead would make every later sync re-hash the file).
+ const entry = diff.next.files[path];
+ entry.chunks = 0;
if (looksBinary(buf)) continue;
- for (const c of await chunkFile(path, buf.toString("utf8"))) {
+ const fileChunks = await chunkFile(path, buf.toString("utf8"));
+ entry.chunks = fileChunks.length;
+ for (const c of fileChunks) {
chunksAdded++;
await chunkWriter.write(JSON.stringify(c) + "\n");
}
@@ -709,6 +723,9 @@ export async function syncRepo(opts: IndexOptions): Promise {
for (const r of langRows) languages[r.lang || "other"] = Number(r.n);
const nextManifest: Manifest = {
...manifest,
+ // Re-stamped every sync so a moved checkout (or a manifest written before
+ // `root` existed) converges on the tree the sync actually walked.
+ root,
files: Number(fileCount),
chunks: Number(chunkCount),
languages,
@@ -808,10 +825,13 @@ function toTextRow(c: Chunk) {
};
}
-function toManifest(stats: IndexStats, embedder?: Manifest["embedder"]): Manifest {
+/** `root` is recorded so readers can map the index back to its tree even when
+ * the index dir lives outside the repo (a custom CX_INDEX_DIR). */
+function toManifest(root: string, stats: IndexStats, embedder?: Manifest["embedder"]): Manifest {
return {
version: INDEX_FORMAT_VERSION,
table: TABLE,
+ root,
vectors: stats.vectors,
...(embedder ? { embedder } : {}),
files: stats.files,
diff --git a/src/core/manifest.ts b/src/core/manifest.ts
index d4d69ae..78c3744 100644
--- a/src/core/manifest.ts
+++ b/src/core/manifest.ts
@@ -31,6 +31,12 @@ export interface Manifest {
version: number;
/** Table name the index lives in (always `chunks` today). */
table: string;
+ /** Absolute repo root this index was built from. Optional: manifests written
+ * before it existed omit it. It matters when the index does NOT sit in the
+ * repo (a custom CX_INDEX_DIR), where nothing else on disk ties the index
+ * back to its tree - tools that reason about repo-relative paths (the
+ * enforcement hook) would otherwise have to guess from a cwd. */
+ root?: string;
vectors: VectorState;
embedder?: EmbedderInfo;
files: number;
diff --git a/src/core/searcher.ts b/src/core/searcher.ts
index 0b8f9b7..5d1348b 100644
--- a/src/core/searcher.ts
+++ b/src/core/searcher.ts
@@ -1,19 +1,15 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The Infino Authors
//
-// The two retrieval doors, shared by the CLI and the MCP server:
+// The one retrieval door, shared by the CLI and the MCP server:
//
-// search - the finding door: one ranked pass fuses exact keyword matching
-// (BM25) with semantic similarity (vectors, RRF) once vectors are
-// ready; ranked keyword search until then. Hits carry chunk
-// content, so answers come straight from results.
-// sql - the power door: read-only SQL over the index, built on the search
-// table functions (bm25_search / hybrid_search) composed with
-// GROUP BY, with {{name}} placeholders embedded server-side for the
-// vector functions.
+// sql - read-only SQL over the index. Ranked retrieval is a table-valued
+// function inside the query (hybrid_search for the fused keyword +
+// semantic pass, bm25_search for keyword-only), so finding, counting,
+// and ranking code are all one SELECT. {{name}} placeholders are
+// embedded server-side for the vector functions.
import type { IndexHandle } from "./context.js";
-import { TABLE, DEFAULT_SEARCH_K } from "./config.js";
import type { Embedder } from "./embedder.js";
import type { Manifest } from "./manifest.js";
@@ -26,8 +22,8 @@ export interface PartialIndex {
}
/** Build the partial-index marker from a manifest, or undefined when the whole
- * tree was indexed. Shared by search (below) and the SQL path (server-side),
- * so every query surfaces the same "results may be incomplete" signal. */
+ * tree was indexed - every query surfaces the same "results may be incomplete"
+ * signal. */
export function partialIndex(manifest: Manifest): PartialIndex | undefined {
if (!manifest.truncatedFiles) return undefined;
const cap = manifest.maxFiles ?? 0;
@@ -41,6 +37,20 @@ export function partialIndex(manifest: Manifest): PartialIndex | undefined {
};
}
+/** Agent-facing readiness note while the vector stage has not finished:
+ * hybrid_search / vector_search rank keyword-only or partial until the
+ * backfill lands, while bm25_search is unaffected. The old search() surfaced
+ * this on every call; the SQL surface must too, or an early query on an
+ * eagerly-building index silently reads as full hybrid recall. Undefined
+ * once vectors are ready. */
+export function vectorsNote(manifest: Manifest): string | undefined {
+ if (manifest.vectors === "ready") return undefined;
+ return (
+ "vectors are still backfilling - hybrid_search/vector_search rank keyword-only or partial " +
+ "results right now; bm25_search is unaffected. This note disappears when vectors are ready."
+ );
+}
+
/** JSON.stringify that survives the engine's bigint row values. */
export function jsonify(value: unknown, pretty = false): string {
return JSON.stringify(
@@ -50,87 +60,6 @@ export function jsonify(value: unknown, pretty = false): string {
);
}
-// --- search -----------------------------------------------------------------
-
-export interface SearchHit {
- path: string;
- startLine: number;
- endLine: number;
- lang: string;
- score: number;
- content: string;
- /** Definition name(s) in this chunk (e.g. "parseConfig"), when known. */
- symbol?: string;
- /** Set when content was capped - Read path:startLine-endLine for the rest. */
- truncated?: boolean;
-}
-
-/** Per-hit content cap: enough to answer "how does X work" from the hit
- * itself (a whole ~60-line chunk fits; only pathological chunks truncate). */
-const HIT_CONTENT_CAP = 4000;
-
-export interface SearchResult {
- query: string;
- /** "hybrid" once vectors are ready; "keyword" while they backfill. */
- ranking: "hybrid" | "keyword";
- hits: SearchHit[];
- note?: string;
- /** Present when the index omitted files over the cap - results may be incomplete. */
- partial?: PartialIndex;
-}
-
-const PROJECTION = ["path", "start_line", "end_line", "lang", "symbol", "content", "score"];
-
-export async function search(
- handle: IndexHandle,
- embedder: Embedder,
- query: string,
- k = DEFAULT_SEARCH_K,
-): Promise {
- const table = handle.db.openTable(TABLE);
- let rows: Array>;
- let ranking: "hybrid" | "keyword";
- if (handle.manifest.vectors === "ready") {
- const indexed = handle.manifest.embedder;
- if (indexed && indexed.model !== embedder.model) {
- throw new Error(
- `query embedder (${embedder.model}) does not match the index embedder (${indexed.model}) - ` +
- `set CX_EMBED_MODEL=${indexed.model} or re-run \`cx index\``,
- );
- }
- const [vector] = await embedder.embed([query]);
- rows = table.hybridSearch("content", query, "embedding", vector, k, { projection: PROJECTION });
- ranking = "hybrid";
- } else {
- rows = table.bm25Search("content", query, k, { projection: PROJECTION });
- ranking = "keyword";
- }
- return {
- query,
- ranking,
- hits: rows.map((r) => {
- const full = String(r.content);
- return {
- path: String(r.path),
- startLine: Number(r.start_line),
- endLine: Number(r.end_line),
- lang: String(r.lang ?? ""),
- score: Number(r.score),
- ...(r.symbol ? { symbol: String(r.symbol) } : {}),
- content: full.slice(0, HIT_CONTENT_CAP),
- ...(full.length > HIT_CONTENT_CAP ? { truncated: true } : {}),
- };
- }),
- ...(ranking === "keyword" && handle.manifest.vectors !== "ready"
- ? { note: "vectors not ready yet - keyword-ranked only (re-run `cx index` or wait for the vector stage to finish)" }
- : {}),
- ...(() => {
- const partial = partialIndex(handle.manifest);
- return partial ? { partial } : {};
- })(),
- };
-}
-
// --- sql --------------------------------------------------------------------
const PLACEHOLDER = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
@@ -182,9 +111,8 @@ export async function runSql(
sql: string,
embeds?: Record,
): Promise>> {
- // The mismatch guard applies to every path that embeds a query, not just
- // `search` - a same-dimension model swap would otherwise return silently
- // wrong vector_search/hybrid_search results through SQL.
+ // Embedder mismatch guard: a same-dimension model swap would otherwise
+ // return silently wrong hybrid_search results.
if (PLACEHOLDER.test(sql)) {
PLACEHOLDER.lastIndex = 0;
const indexed = handle.manifest.embedder;
diff --git a/src/core/usage.ts b/src/core/usage.ts
index 4d0b67c..6b17c09 100644
--- a/src/core/usage.ts
+++ b/src/core/usage.ts
@@ -7,9 +7,9 @@
// estimate (we can't run the agent's tokenizer), and nothing leaves the
// machine: the ledger is a plain JSONL file inside the repo's index dir.
-import { appendFileSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
-import { join, resolve } from "node:path";
-import { jsonify, type SearchResult } from "./searcher.js";
+import { appendFileSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { jsonify } from "./searcher.js";
/** Rough tokens-per-char - the standard heuristic for English + code. Kept
* deliberately simple: usage reports `~` figures, not a billed count. */
@@ -38,52 +38,14 @@ export const receiptEnabled = (): boolean =>
* it doesn't duplicate the repo. */
export interface UsageEntry {
ts: string;
- tool: "search" | "sql";
+ tool: "sql";
query: string;
returnedTokens: number;
- /** search only: whole-file size of the distinct files the hits came from. */
- wholeFileTokens?: number | null;
- ranking?: "hybrid" | "keyword";
- /** search only: the response, as the regions you'd jump to. */
- hits?: Array<{ path: string; startLine: number; endLine: number }>;
- /** sql only. */
rows?: number;
- /** sql only: a truncated preview of the returned rows (the answer itself). */
+ /** A truncated preview of the returned rows (the answer itself). */
rowsPreview?: string;
}
-/** Best-effort sum of the on-disk size (as tokens) of the distinct files the
- * hits came from - the "what reading them whole would cost" counterfactual.
- * Conservative: a file we can't stat is skipped, never guessed, so the figure
- * only ever understates the whole-file cost. null when nothing was stattable. */
-function wholeFileTokens(paths: string[], root: string): number | null {
- let total = 0;
- let counted = 0;
- for (const p of paths) {
- try {
- total += Math.ceil(statSync(resolve(root, p)).size / CHARS_PER_TOKEN);
- counted++;
- } catch {
- // unreadable/moved since indexing - drop it rather than mislead
- }
- }
- return counted > 0 ? total : null;
-}
-
-export function searchEntry(result: SearchResult, root: string): UsageEntry {
- const hits = result.hits.map((h) => ({ path: h.path, startLine: h.startLine, endLine: h.endLine }));
- const files = [...new Set(hits.map((h) => h.path))];
- return {
- ts: new Date().toISOString(),
- tool: "search",
- query: result.query,
- returnedTokens: result.hits.reduce((n, h) => n + estTokens(h.content), 0),
- wholeFileTokens: wholeFileTokens(files, root),
- ranking: result.ranking,
- hits,
- };
-}
-
const ROWS_PREVIEW_CAP = 2000;
export function sqlEntry(query: string, rows: Array>): UsageEntry {
@@ -111,17 +73,7 @@ const plural = (n: number, one: string, many: string): string => `${n} ${n === 1
* Mutates and appends the running total when a session is supplied. */
export function formatReceipt(entry: UsageEntry, session?: SessionUsage): string {
const parts: string[] = [];
- if (entry.tool === "search") {
- const hits = entry.hits ?? [];
- const files = new Set(hits.map((h) => h.path)).size;
- // Just what was returned - no "vs whole file" counterfactual here: it's an
- // estimate of a road not taken, not a measured saving, so we don't assert
- // it after every response. The raw wholeFileTokens still lives in the entry
- // for anyone who wants to reason about it from the ledger.
- parts.push(`returned ~${fmtTokens(entry.returnedTokens)} tokens | ${plural(hits.length, "chunk", "chunks")} / ${plural(files, "file", "files")}`);
- } else {
- parts.push(`returned ~${fmtTokens(entry.returnedTokens)} tokens | ${plural(entry.rows ?? 0, "row", "rows")}`);
- }
+ parts.push(`returned ~${fmtTokens(entry.returnedTokens)} tokens | ${plural(entry.rows ?? 0, "row", "rows")}`);
if (session) {
session.queries++;
session.returnedTokens += entry.returnedTokens;
diff --git a/src/mcp/ensure.ts b/src/mcp/ensure.ts
index 60f8bb1..77ebe49 100644
--- a/src/mcp/ensure.ts
+++ b/src/mcp/ensure.ts
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The Infino Authors
//
-// Auto-index on first query. A search/sql against a repo that has never been
+// Auto-index safety net. A sql query against a repo that has never been
// indexed builds the index inline and then answers on the same call, instead
// of erroring with "index it first". Staged readiness makes this cheap: the
// build resolves the moment keyword search is live (seconds), with vectors
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index ad733d4..9d71afe 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -1,29 +1,29 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The Infino Authors
//
-// The dedicated MCP server: three tools over one code index.
+// The dedicated MCP server: two tools over one code index.
//
-// search - find code: exact terms AND meaning in one ranked pass
-// sql - the power door: relevance-ranked aggregation over the search
-// table functions (bm25_search / hybrid_search + GROUP BY)
+// sql - the only search surface: ranked retrieval via the search
+// table functions (hybrid_search / bm25_search) composed
+// freely with GROUP BY, regexp_like, and plain SQL
// reindex - sync from the working tree; replies the moment keyword
// search is live and backfills vectors in-process
//
-// Three tools, deliberately: one way to find, one way to count, one way to
-// stay fresh - every additional near-duplicate retrieval tool worsens the
-// agent's tool selection. Results carry took_ms - server-side time for
-// the call (query embedding included where one happens; no transport).
+// Two tools, deliberately: one way to query, one way to stay fresh -
+// every additional near-duplicate retrieval tool worsens the agent's
+// tool selection. Results carry took_ms - server-side time for the
+// call (query embedding included where one happens; no transport).
import { existsSync } from "node:fs";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { connect } from "@infino-ai/infino";
-import { indexDir, resolveRoot, TABLE, DEFAULT_CAPS, DEFAULT_SEARCH_K } from "../core/config.js";
+import { indexDir, resolveRoot, TABLE, DEFAULT_CAPS } from "../core/config.js";
import { readManifest, type Manifest } from "../core/manifest.js";
import type { IndexHandle } from "../core/context.js";
-import { search, runSql, jsonify, partialIndex } from "../core/searcher.js";
-import { newSession, receiptEnabled, searchEntry, sqlEntry, formatReceipt, recordUsage } from "../core/usage.js";
+import { runSql, jsonify, partialIndex, vectorsNote } from "../core/searcher.js";
+import { newSession, receiptEnabled, sqlEntry, formatReceipt, recordUsage } from "../core/usage.js";
import {
indexRepoStaged,
syncRepo,
@@ -64,7 +64,7 @@ export async function serveMcp(rootPath?: string): Promise {
// keeps the stat walk off the hot path (~20ms to ~2s depending on repo size).
const autoSyncEnabled = !["0", "false", "no"].includes((process.env.CX_AUTO_SYNC ?? "").toLowerCase());
const syncIntervalMs = Number(process.env.CX_SYNC_INTERVAL_SECS ?? 30) * 1000;
- // A search/sql on a never-indexed repo builds the index inline, then answers
+ // A sql query on a never-indexed repo builds the index inline, then answers
// on the same call (staged: keyword search live in seconds). Off restores the
// strict "index it first" error.
const autoIndexEnabled = !["0", "false", "no"].includes((process.env.CX_AUTO_INDEX ?? "").toLowerCase());
@@ -168,112 +168,56 @@ export async function serveMcp(rootPath?: string): Promise {
(stats.vectors === "building" ? " and vectors are backfilling in the background" : ""),
});
- const timed = (fn: () => T): { value: T; tookMs: number } => {
- const t0 = performance.now();
- const value = fn();
- return { value, tookMs: Math.round((performance.now() - t0) * 1000) / 1000 };
- };
+ // Build the index up front rather than on the first query. The server
+ // starts with the session, so kicking the staged build here means keyword
+ // search is typically live (and vectors backfilling) before the agent asks
+ // anything. Best-effort: queries still ensure the index inline.
+ if (autoIndexEnabled) {
+ setImmediate(() => {
+ try {
+ const ctx = repoFor(undefined);
+ if (!getHandle(ctx)) void buildIndex(ctx)?.catch(() => undefined);
+ else maybeAutoSync(ctx);
+ } catch {
+ // unresolvable root or similar - the first query will surface it
+ }
+ });
+ }
const server = new McpServer(
{ name: "code-context", version: "0.1.2" },
{
instructions:
- "code-context is a local ranked index over this repository - semantic + keyword search and " +
- "SQL over the whole codebase. Reach for it whenever you need to understand or find code: " +
- "understanding how a subsystem works, finding code by meaning or by exact term, gathering " +
- "context before an edit, locating a bug or the code behind a behaviour, reviewing existing " +
- "patterns, planning a refactor, understanding the architecture for feature work, or spotting " +
- "similar/duplicate implementations. It is the primary tool for finding and understanding " +
- "code here, for almost any question about this codebase. Three tools:\n" +
- "- search - find code by meaning or terms across files and understand how something works, " +
- "in one ranked pass.\n" +
- "- sql - counts, rankings, and aggregates over the whole repo in one query, including " +
- "relevance-ranked aggregation ('which files have the most code about X') that file tools " +
- "cannot express at any budget.\n" +
- "- reindex - sync the index after the working tree changes.\n" +
- "Treat a hit's content as authoritative: when it answers the question, answer from it and " +
- "cite path plus line range - you don't need to re-confirm with grep or by opening the file. " +
- "Read a file only for a hit marked truncated (its cited range), or when the results genuinely " +
- "don't cover the question. When one search isn't enough, refine the query and search again - " +
- "the ranked hits are already the relevant regions.\n" +
+ "code-context is a local ranked index over this repository. All code search goes through " +
+ "the sql tool - ranked retrieval is a table-valued function inside your query, so finding, " +
+ "understanding, counting, and ranking code are all one read-only SELECT. It is the primary " +
+ "tool for almost any question about this codebase. Two tools:\n" +
+ "- sql - THE search surface. Table: chunks(path, start_line, end_line, lang, symbol, " +
+ "content[, embedding]). Find code: SELECT path, start_line, end_line, symbol, content FROM " +
+ "hybrid_search('chunks','content','','embedding', {{q}}, 10) with the embed map " +
+ '{"q":""} - keyword + semantic fused in one ranked pass. ' +
+ "bm25_search('chunks','content','', k) is the keyword arm (use it while vectors are " +
+ "still backfilling). Rank and aggregate compose: SELECT path, SUM(end_line - start_line + 1) " +
+ "AS lines FROM bm25_search(...) GROUP BY path ORDER BY lines DESC. regexp_like(content, " +
+ "'pattern') filters in WHERE. vector_search('chunks','embedding', {{q}}, k) ranks by " +
+ "meaning alone; prefer hybrid_search, which keeps the keyword arm too. Always rank with a " +
+ "search TVF rather than scanning the table with LIKE.\n" +
+ "- reindex - sync the index after the working tree changes (it also auto-syncs in the " +
+ "background).\n" +
+ "The index builds as the server starts, so it is typically ready before your first query; " +
+ "if a result notes vectors are still backfilling, ranking is keyword-only for the moment.\n" +
+ "Treat a returned chunk's content as authoritative: when it answers the question, answer " +
+ "from it and cite path plus line range - you don't need to re-confirm with grep or by " +
+ "opening the file. Read a file only for what the chunks don't show (the cited range via " +
+ "offset/limit), never whole files.\n" +
"Every tool takes an optional 'path' (an absolute repo root): omit it for the default repo, " +
- "or set it to target a specific one when you're working across more than one repo in a session.\n" +
- "If a result carries a 'partial' marker, the repo exceeded the index's file cap and some files " +
- "were left out: treat a missing match as possibly-unindexed, not proof it's absent.\n" +
- "Each result carries a 'usage' receipt (tokens returned, chunks/files, and a session running " +
- "total) - computed locally, nothing leaves the machine. Surface it to the user so they can see " +
- "how much context the ranked results put into the conversation.",
- },
- );
-
- server.registerTool(
- "search",
- {
- title: "Code search (exact terms + meaning)",
- description:
- "Semantic + keyword code search over the indexed repository - a strong default for finding " +
- "and understanding code. Use it to: understand how a subsystem or feature works, find code " +
- "by meaning when you don't know the exact name, locate the code behind a behaviour or bug, " +
- "gather context before making a change, review existing implementations and patterns, find " +
- "everything a refactor would touch, understand the architecture for feature work, or spot " +
- "similar/duplicate code. One pass fuses exact keyword matching (BM25: identifiers, error " +
- "strings, function names, stemmed and scored) with semantic similarity (renamed symbols, " +
- "paraphrases, 'where is X handled'), so it works whether or not you know the words. Each hit " +
- "carries path, line range, and the chunk content with a relevance score - treat it as " +
- "authoritative and answer directly from it, citing path plus line range; you don't need to " +
- "re-confirm a hit with grep or by opening the file. When one search isn't enough, refine the " +
- "query and search again - the index has already ranked the relevant regions. Read a file only " +
- "for a hit marked truncated (its cited start-end range via offset/limit), or when results " +
- "genuinely don't cover the question. (Until the index's vector stage finishes, results are " +
- "keyword-ranked and say so.) The result includes a 'usage' field - a one-line receipt " +
- "(tokens returned, chunks/files, session total). After you " +
- "answer, end your reply by showing that 'usage' line to the user verbatim.",
- inputSchema: {
- query: z.string().describe("What you're looking for - terms, a phrase, or a description."),
- k: z.number().int().positive().max(50).default(DEFAULT_SEARCH_K).describe("Maximum hits."),
- path: z
- .string()
- .optional()
- .describe(
- "Absolute path to the repository root to search. Defaults to the server's configured root; " +
- "set it to target a specific repo when a session spans more than one.",
- ),
- },
- },
- async ({ query, k, path }) => {
- let ctx: RepoCtx;
- try {
- ctx = repoFor(path);
- } catch (err) {
- return fail((err as Error).message);
- }
- let ensured: EnsureResult;
- try {
- ensured = await ensureIndexed(ctx, { autoIndexEnabled, getHandle, build: buildIndex });
- } catch (err) {
- return fail(`indexing failed: ${(err as Error).message}`);
- }
- if ("needsIndex" in ensured) return noIndex(ctx.root);
- const { handle, autoIndexed } = ensured;
- if (!autoIndexed) maybeAutoSync(ctx); // a fresh build is already current
- try {
- const t0 = performance.now();
- const result = await search(handle, getEmbedder(), query, k);
- let usage: string | undefined;
- if (receiptOn) {
- const entry = searchEntry(result, ctx.root);
- recordUsage(ctx.dir, entry);
- usage = formatReceipt(entry, session);
- }
- return ok({
- ...result,
- ...(autoIndexed ? { auto_indexed: autoIndexNote(autoIndexed) } : {}),
- took_ms: Math.round((performance.now() - t0) * 1000) / 1000,
- ...(usage ? { usage } : {}),
- });
- } catch (err) {
- return fail(`search failed: ${(err as Error).message}`);
- }
+ "or set it to target a specific one when you're working across more than one repo in a " +
+ "session.\n" +
+ "If a result carries a 'partial' marker, the repo exceeded the index's file cap and some " +
+ "files were left out: treat a missing match as possibly-unindexed, not proof it's absent.\n" +
+ "Each result carries a 'usage' receipt (tokens returned, rows, and a session running total) " +
+ "- computed locally, nothing leaves the machine. Surface it to the user so they can see " +
+ "how much context the results put into the conversation.",
},
);
@@ -282,20 +226,22 @@ export async function serveMcp(rootPath?: string): Promise {
{
title: "SQL over the code index",
description:
- "Whole-repo analytical questions that file tools cannot express at any budget: counts, " +
- "rankings, GROUP BY across the codebase in one query, " +
- `on table ${TABLE}(path, start_line, end_line, lang, content[, embedding]). ` +
- "Search functions are callable as table-valued relations, so one query can rank AND " +
- "aggregate: bm25_search('" + TABLE + "','content','terms', k) needs no embedding; " +
- "hybrid_search('" + TABLE + "','content','terms','embedding', {{q}}, k) and " +
- "vector_search('" + TABLE + "','embedding', {{q}}, k) take a {{name}} placeholder with an " +
- 'embed map: {"q":"query text"}. The canonical move - "which files have the most code about ' +
- 'X": SELECT path, SUM(end_line - start_line + 1) AS lines FROM ' +
- `bm25_search('${TABLE}','content','', 300) GROUP BY path ORDER BY lines DESC LIMIT 15. ` +
- "Build queries on bm25_search/hybrid_search so results are ranked by relevance to the topic, " +
- "not on a raw scan of the whole table. Read-only, single statement. The result includes a " +
- "'usage' field - a one-line receipt (tokens returned, rows, session total). After " +
- "you answer, end your reply by showing that 'usage' line to the user verbatim.",
+ "The way to search this codebase - ranked retrieval and whole-repo analytics in one " +
+ `read-only SQL statement over ${TABLE}(path, start_line, end_line, lang, symbol, ` +
+ "content[, embedding]). Search functions are table-valued relations. Find code: SELECT " +
+ "path, start_line, end_line, symbol, content FROM hybrid_search('" + TABLE + "','content'," +
+ "'','embedding', {{q}}, 10) with the embed map " +
+ '{"q":""} - keyword + semantic fused in one ranked pass; use ' +
+ "bm25_search('" + TABLE + "','content','', k) while vectors are still backfilling. " +
+ "Count/rank: SELECT path, SUM(end_line - start_line + 1) AS lines FROM bm25_search('" +
+ TABLE + "','content','', 300) GROUP BY path ORDER BY lines DESC LIMIT 15. " +
+ "regexp_like(content, 'pattern') works in WHERE. vector_search('" + TABLE + "','embedding', " +
+ "{{q}}, k) ranks by meaning alone; prefer hybrid_search, which keeps the keyword arm too. " +
+ "Always rank with a search TVF rather than scanning the table with LIKE. Read-only, single " +
+ "statement. Treat returned chunk content " +
+ "as authoritative - answer from it and cite path:start-end. The result includes a 'usage' " +
+ "field - a one-line receipt (tokens returned, rows, session total). After you answer, end " +
+ "your reply by showing that 'usage' line to the user verbatim.",
inputSchema: {
query: z
.string()
@@ -333,6 +279,7 @@ export async function serveMcp(rootPath?: string): Promise {
const t0 = performance.now();
const rows = await runSql(handle, getEmbedder(), query, embed as Record | undefined);
const partial = partialIndex(handle.manifest);
+ const note = vectorsNote(handle.manifest);
let usage: string | undefined;
if (receiptOn) {
const entry = sqlEntry(query, rows);
@@ -341,6 +288,7 @@ export async function serveMcp(rootPath?: string): Promise {
}
return ok({
rows,
+ ...(note ? { note } : {}),
...(partial ? { partial } : {}),
...(autoIndexed ? { auto_indexed: autoIndexNote(autoIndexed) } : {}),
took_ms: Math.round((performance.now() - t0) * 1000) / 1000,
diff --git a/test/auto-index.integration.test.ts b/test/auto-index.integration.test.ts
index 4485cac..0aac468 100644
--- a/test/auto-index.integration.test.ts
+++ b/test/auto-index.integration.test.ts
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { connect, type Connection } from "@infino-ai/infino";
import { indexRepoStaged, type IndexStats } from "../src/core/indexer.js";
import { readManifest } from "../src/core/manifest.js";
-import { search } from "../src/core/searcher.js";
+import { runSql } from "../src/core/searcher.js";
import type { IndexHandle } from "../src/core/context.js";
import type { Embedder } from "../src/core/embedder.js";
import type { RepoCtx } from "../src/mcp/repos.js";
@@ -76,9 +76,13 @@ describe("auto-index on first query (end to end)", () => {
expect(existsSync(ctx.dir)).toBe(true);
// The freshly built index actually answers a query.
- const hits = await search(res.handle, fakeEmbedder, "verifySession token", 5);
- expect(hits.hits.length).toBeGreaterThan(0);
- expect(hits.hits[0].path).toContain("auth.ts");
+ const rows = await runSql(
+ res.handle,
+ fakeEmbedder,
+ "SELECT path FROM bm25_search('chunks','content','verifySession token', 5)",
+ );
+ expect(rows.length).toBeGreaterThan(0);
+ expect(String(rows[0].path)).toContain("auth.ts");
});
it("does not rebuild when the index already exists", async () => {
diff --git a/test/enforcement.test.ts b/test/enforcement.test.ts
new file mode 100644
index 0000000..63bf1a4
--- /dev/null
+++ b/test/enforcement.test.ts
@@ -0,0 +1,741 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright The Infino Authors
+//
+// The enforcement hook is the one place where being wrong costs the agent a
+// capability: a deny it should not have issued leaves the target reachable by
+// neither grep nor sql. So the cases below are mostly about what must stay
+// ALLOWED - every file class the indexer skips (lockfiles, `.csv`, `LICENSE`,
+// `vendor/`, symlinks, anything not yet synced), every file it fingerprinted
+// without chunking a row out of, and every index state short of fully live.
+//
+// The other half is parsing: the hook reads a Bash command as shell, so a
+// pipeline (`| head`), a trailing comment, a heredoc body, a wrapper
+// (`timeout 5`, `command`, `xargs -a`), an absolute launcher path and git's
+// global flags all have to land on the same decision the bare command would.
+//
+// The hook is driven the way the client drives it: a fresh node process with
+// the payload on stdin, reading a real index directory. Silence on stdout is
+// "allow" - the hook only speaks to deny or ask.
+//
+// Fixture indexes are hand-written (manifest + filestate + an empty table
+// directory) rather than produced by the indexer: what the hook reads is those
+// two files, and building them by hand keeps the suite in milliseconds. The
+// real-indexer block at the bottom pins the agreement those fixtures assume.
+import { spawnSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { connect } from "@infino-ai/infino";
+import { indexRepo, syncRepo } from "../src/core/indexer.js";
+import { readFileState } from "../src/core/filestate.js";
+import { readManifest } from "../src/core/manifest.js";
+import type { Embedder } from "../src/core/embedder.js";
+
+/** The hook as published (and as `cx install` copies it). */
+const HOOK = new URL("../hooks/deny-grep.mjs", import.meta.url).pathname;
+
+/** Files the fixture records in filestate - the index's own account of what it
+ * holds. `.pre-commit-config.yaml` is there on purpose: real indexes do carry
+ * dotfiles, which the old dot-path rule wrongly waved through. */
+const INDEXED = ["src/a.rs", "src/auth.rs", "README.md", ".pre-commit-config.yaml"];
+
+/** A filestate key as `writeIndex` writes it. A bare path records rows
+ * (`chunks: DEFAULT_FIXTURE_CHUNKS`); a tuple pins the count - `0` for a file
+ * the indexer fingerprinted but chunked nothing out of, `undefined` for state
+ * written before the field existed. */
+type FileFixture = string | [path: string, chunks: number | undefined];
+
+/** Rows a fixture file records unless the case pins its own count. */
+const DEFAULT_FIXTURE_CHUNKS = 3;
+
+/** A Python package the indexer fingerprints whole but can chunk only one file
+ * of: the empty `__init__.py` and the NUL-carrying module yield no rows. */
+const PKG_EMPTY_INIT = "pkg/__init__.py";
+const PKG_BINARY = "pkg/blob.py";
+const PKG_SOURCE = "pkg/thing.py";
+
+/** A package whose every file is chunk-less, i.e. a directory the table holds
+ * nothing at all for. */
+const HOLLOW_INIT = "hollow/__init__.py";
+
+/** Files that exist in the tree but not in the index: the classes the indexer
+ * skips, plus one (`notes.txt`) that simply has not been synced yet. */
+const UNINDEXED = ["package-lock.json", "data.csv", "LICENSE", "vendor/lib.js", "notes.txt"];
+
+/** A fully live index: vectors ready, nothing truncated. */
+const READY_MANIFEST = {
+ version: 2,
+ table: "chunks",
+ vectors: "ready",
+ files: INDEXED.length,
+ chunks: 9,
+ languages: { rust: 6, markdown: 3 },
+ indexedAt: "2026-01-01T00:00:00.000Z",
+ indexMs: 12,
+};
+
+/** Stand-in for the engine's table directory next to the manifest. */
+const TABLE_DIR = "chunks-0123456789ab-0";
+
+/** The developer's own shell must not decide a case, so the CX knobs the hook
+ * reads are cleared and set per invocation. */
+const BASE_ENV: NodeJS.ProcessEnv = { ...process.env };
+delete BASE_ENV.CX_NO_ENFORCE;
+delete BASE_ENV.CX_INDEX_DIR;
+delete BASE_ENV.CX_MAX_FILE_BYTES;
+
+interface IndexShape {
+ /** Manifest fields layered over READY_MANIFEST. */
+ manifest?: Record;
+ /** filestate keys; null writes no filestate at all. */
+ files?: FileFixture[] | null;
+ /** false leaves the manifest orphaned (no table on disk). */
+ table?: boolean;
+}
+
+const temps: string[] = [];
+
+function tempDir(prefix: string): string {
+ const dir = mkdtempSync(join(tmpdir(), prefix));
+ temps.push(dir);
+ return dir;
+}
+
+/** A tree carrying every file class, with a symlinked directory (the walker
+ * never descends into one, so nothing under it is ever indexed). */
+function buildTree(): string {
+ const repo = tempDir("cx-enforce-repo-");
+ for (const rel of [...INDEXED, ...UNINDEXED]) {
+ mkdirSync(dirname(join(repo, rel)), { recursive: true });
+ writeFileSync(join(repo, rel), "let auth = 1;\n");
+ }
+ const outside = tempDir("cx-enforce-outside-");
+ writeFileSync(join(outside, "x.rs"), "let auth = 2;\n");
+ symlinkSync(outside, join(repo, "shared"), "dir");
+ return repo;
+}
+
+function writeIndex(indexDir: string, shape: IndexShape = {}): void {
+ const { manifest = {}, files = INDEXED, table = true } = shape;
+ mkdirSync(indexDir, { recursive: true });
+ writeFileSync(join(indexDir, "codecontext.json"), JSON.stringify({ ...READY_MANIFEST, ...manifest }));
+ if (files) {
+ const entries = files.map((f) => {
+ const [path, chunks] = typeof f === "string" ? [f, DEFAULT_FIXTURE_CHUNKS] : f;
+ return [path, { size: 14, mtimeMs: 1, hash: "abc", ...(chunks === undefined ? {} : { chunks }) }];
+ });
+ writeFileSync(join(indexDir, "filestate.json"), JSON.stringify({ version: 1, files: Object.fromEntries(entries) }));
+ }
+ if (table) mkdirSync(join(indexDir, TABLE_DIR), { recursive: true });
+}
+
+interface HookResult {
+ decision: "allow" | "deny" | "ask";
+ context?: string;
+}
+
+/** One hook run. Empty stdout means the hook said nothing, which the client
+ * reads as allow. */
+function hook(payload: unknown, env: NodeJS.ProcessEnv = {}): HookResult {
+ const run = spawnSync(process.execPath, [HOOK], {
+ input: JSON.stringify(payload),
+ encoding: "utf8",
+ env: { ...BASE_ENV, ...env },
+ });
+ if (run.status !== 0) throw new Error(`hook exited ${run.status}: ${run.stderr}`);
+ const out = run.stdout.trim();
+ if (out === "") return { decision: "allow" };
+ const emitted = JSON.parse(out).hookSpecificOutput as {
+ permissionDecision?: HookResult["decision"];
+ additionalContext?: string;
+ };
+ return { decision: emitted.permissionDecision ?? "allow", context: emitted.additionalContext };
+}
+
+describe("grep enforcement", () => {
+ let repo: string;
+ let indexDir: string;
+
+ beforeEach(() => {
+ repo = buildTree();
+ indexDir = join(repo, ".infino");
+ writeIndex(indexDir, { manifest: { root: repo } });
+ });
+
+ afterEach(() => {
+ while (temps.length > 0) rmSync(temps.pop() as string, { recursive: true, force: true });
+ });
+
+ const bash = (command: string, env: NodeJS.ProcessEnv = {}): string =>
+ hook({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: repo, tool_input: { command } }, env).decision;
+
+ const grepTool = (toolInput: Record, env: NodeJS.ProcessEnv = {}): string =>
+ hook({ hook_event_name: "PreToolUse", tool_name: "Grep", cwd: repo, tool_input: toolInput }, env).decision;
+
+ describe("coverage comes from the index, not from re-derived skip rules", () => {
+ it("allows a lockfile the chunker never indexes", () => {
+ expect(bash("grep -n commander package-lock.json")).toBe("allow");
+ });
+
+ it("allows an extension outside the language allowlist", () => {
+ expect(bash("grep -n 1 data.csv")).toBe("allow");
+ });
+
+ it("allows an extensionless file that is not a known basename", () => {
+ expect(bash("grep -n Apache LICENSE")).toBe("allow");
+ });
+
+ it("allows a skipped directory even though it is not gitignored", () => {
+ expect(bash("grep -rn Foo vendor/")).toBe("allow");
+ });
+
+ it("allows a symlinked directory the walker never descends into", () => {
+ expect(bash("grep -rn x shared/")).toBe("allow");
+ });
+
+ it("allows a file that is on disk but absent from filestate", () => {
+ expect(bash("grep -n auth notes.txt")).toBe("allow");
+ });
+
+ it("allows when any one of several targets is uncovered", () => {
+ expect(bash("grep -n auth src/a.rs data.csv")).toBe("allow");
+ });
+
+ it("denies an indexed source file", () => {
+ expect(bash("grep -n auth src/a.rs")).toBe("deny");
+ });
+
+ it("denies an indexed dotfile", () => {
+ expect(bash("grep -n repos .pre-commit-config.yaml")).toBe("deny");
+ });
+
+ it("denies an indexed directory, by relative and absolute path alike", () => {
+ expect(bash("grep -rn auth src")).toBe("deny");
+ expect(bash(`grep -rn auth ${join(repo, "src")}`)).toBe("deny");
+ });
+
+ it("allows a path outside the repo", () => {
+ expect(bash("grep -n auth /etc/hosts")).toBe("allow");
+ });
+ });
+
+ // filestate fingerprints every readable candidate, including files it then
+ // chunks nothing out of - so its key set is a superset of the table's paths,
+ // and only the recorded row count separates the two.
+ describe("a fingerprinted file with no chunk rows is not covered", () => {
+ beforeEach(() => {
+ for (const rel of [PKG_EMPTY_INIT, PKG_BINARY, PKG_SOURCE, HOLLOW_INIT]) {
+ mkdirSync(dirname(join(repo, rel)), { recursive: true });
+ writeFileSync(join(repo, rel), rel === PKG_SOURCE ? "def thing():\n return 1\n" : "");
+ }
+ writeIndex(indexDir, {
+ manifest: { root: repo },
+ files: [
+ ...INDEXED,
+ [PKG_EMPTY_INIT, 0],
+ [PKG_BINARY, 0],
+ [PKG_SOURCE, 4],
+ [HOLLOW_INIT, 0],
+ ],
+ });
+ });
+
+ it("allows an empty __init__.py and a NUL-carrying module, denies their real sibling", () => {
+ expect(bash(`rg -n main ${PKG_EMPTY_INIT}`)).toBe("allow");
+ expect(bash(`rg -n main ${PKG_BINARY}`)).toBe("allow");
+ expect(bash(`rg -n main ${PKG_SOURCE}`)).toBe("deny");
+ });
+
+ it("covers a package directory only through the file that produced rows", () => {
+ expect(bash("rg -n main pkg/")).toBe("deny");
+ expect(bash("rg -n main pkg")).toBe("deny");
+ // Every key under this one is chunk-less: sql has nothing to answer with.
+ expect(bash("rg -n main hollow/")).toBe("allow");
+ expect(bash("rg -n main hollow")).toBe("allow");
+ });
+
+ it("applies the same rule to globs", () => {
+ expect(grepTool({ pattern: "main", glob: "*.py" })).toBe("deny");
+ expect(grepTool({ pattern: "main", glob: "pkg/thing.py" })).toBe("deny");
+ expect(grepTool({ pattern: "main", glob: "hollow/*.py" })).toBe("allow");
+ expect(grepTool({ pattern: "main", glob: "**/__init__.py" })).toBe("allow");
+ expect(grepTool({ pattern: "main", path: HOLLOW_INIT })).toBe("allow");
+ });
+
+ it("covers a count-less entry only in a mixed (mid-upgrade) state", () => {
+ // A wholly pre-upgrade state fails open (see the round-2 case below);
+ // once any entry carries a count, the unstamped rest keeps the
+ // back-compatible covered reading until the next sync stamps it.
+ writeIndex(indexDir, { manifest: { root: repo }, files: [["src/a.rs", undefined], "README.md"] });
+ expect(bash("grep -n auth src/a.rs")).toBe("deny");
+ });
+
+ it("fails open when nothing in the state produced a row", () => {
+ writeIndex(indexDir, { manifest: { root: repo }, files: [["src/a.rs", 0], ["README.md", 0]] });
+ expect(bash("grep -n auth src/a.rs")).toBe("allow");
+ expect(bash("rg foo")).toBe("allow");
+ });
+ });
+
+ describe("command shapes", () => {
+ it("denies a repo-wide search with no explicit target", () => {
+ expect(bash("rg foo")).toBe("deny");
+ });
+
+ it("denies a grep launched after another command in the same line", () => {
+ expect(bash("cd src && rg -n auth")).toBe("deny");
+ expect(bash("cd src ; rg -n auth")).toBe("deny");
+ expect(bash("test -d src || rg -n auth")).toBe("deny");
+ });
+
+ it("denies a multi-word quoted pattern over an indexed directory", () => {
+ expect(bash('grep -rn "let auth" src/')).toBe("deny");
+ expect(bash("grep -rn 'let auth' src/")).toBe("deny");
+ });
+
+ it("denies through env-assignment and env/sudo/time/nice wrappers", () => {
+ expect(bash("env FOO=1 grep -n x src/a.rs")).toBe("deny");
+ expect(bash("sudo grep -n x src/a.rs")).toBe("deny");
+ expect(bash("time grep -n x src/a.rs")).toBe("deny");
+ expect(bash("FOO=1 grep -n x src/a.rs")).toBe("deny");
+ });
+
+ it("denies git grep", () => {
+ expect(bash("git grep -n auth src/a.rs")).toBe("deny");
+ });
+
+ it("allows grep as a pipe filter, never splitting on |", () => {
+ expect(bash("cargo test 2>&1 | grep FAILED")).toBe("allow");
+ expect(bash("ls | grep foo")).toBe("allow");
+ expect(bash("rg --version | grep ripgrep")).toBe("allow");
+ });
+
+ it("allows a pattern-less invocation", () => {
+ expect(bash("grep --version")).toBe("allow");
+ });
+
+ it("reads a redirection as a destination, not as a target", () => {
+ expect(bash("rg foo > hits.txt")).toBe("deny");
+ expect(bash("rg foo >hits.txt")).toBe("deny");
+ expect(bash("grep -rn auth src/ 2>&1")).toBe("deny");
+ expect(bash("grep -n 1 data.csv > hits.txt")).toBe("allow");
+ // An input redirection searches a stream the index cannot speak for.
+ expect(bash("grep -n auth < notes.txt")).toBe("allow");
+ });
+
+ it("treats a quoted token as pattern text, not as shell syntax", () => {
+ // Reading the `>` as a redirection would drop the pattern and make the
+ // unindexed file look like a repo-wide search.
+ expect(bash('grep -n "> TODO" notes.txt')).toBe("allow");
+ expect(bash('grep -n "> TODO" src/a.rs')).toBe("deny");
+ expect(bash('grep -n -- "-n" src/a.rs')).toBe("deny");
+ });
+
+ it("asks when the fallback marker prefixes the grep", () => {
+ expect(bash("CX_GREP_FALLBACK=1 grep -n auth src/a.rs")).toBe("ask");
+ expect(bash("cd src && CX_GREP_FALLBACK=1 rg -n auth")).toBe("ask");
+ });
+ });
+
+ // A search's own operands end at the pipeline: the words of `| head -5` are
+ // another command's, and reading them as targets nothing covers turned the
+ // most common grep idiom there is into an allow.
+ describe("a search whose output is piped onward", () => {
+ it("denies the search regardless of the filter behind the pipe", () => {
+ expect(bash("rg -n auth src | head -5")).toBe("deny");
+ expect(bash("rg -n auth src/a.rs | head")).toBe("deny");
+ expect(bash("rg -n auth | head -20")).toBe("deny");
+ expect(bash("rg -n auth src | wc -l")).toBe("deny");
+ expect(bash("grep -rn auth src | sort")).toBe("deny");
+ expect(bash("rg -n auth src|head")).toBe("deny");
+ expect(bash("rg --files-with-matches auth src | xargs cat")).toBe("deny");
+ });
+
+ it("denies a search with a trailing comment", () => {
+ expect(bash("rg -n auth src # note")).toBe("deny");
+ expect(bash("rg -n auth src #note")).toBe("deny");
+ });
+
+ it("still allows a grep that filters another command's output", () => {
+ expect(bash("cargo test | grep FAILED")).toBe("allow");
+ expect(bash("cargo test 2>&1 | grep -i error")).toBe("allow");
+ expect(bash("ps aux | grep node")).toBe("allow");
+ expect(bash("env | grep PATH")).toBe("allow");
+ expect(bash("git log --oneline | grep fix")).toBe("allow");
+ expect(bash("cat /etc/hosts | grep -v '#' | grep local")).toBe("allow");
+ expect(bash("rg --version | grep ripgrep")).toBe("allow");
+ });
+
+ it("keeps a quoted pipe inside the pattern literal", () => {
+ expect(bash('grep -rn "a|b" src/')).toBe("deny");
+ expect(bash("rg -n '#include' src")).toBe("deny");
+ expect(bash("grep -rn 'a|b' data.csv")).toBe("allow");
+ });
+
+ it("asks when the fallback marker prefixes a piped search", () => {
+ expect(bash("CX_GREP_FALLBACK=1 rg -n auth src | head")).toBe("ask");
+ });
+ });
+
+ // A heredoc body is data: an agent writing a script or a doc that mentions a
+ // search must not be told to use sql instead.
+ describe("heredoc bodies", () => {
+ it("allows a script whose body merely contains a search line", () => {
+ expect(bash("cat > run.sh <<'SH'\nrg -n auth src\nSH")).toBe("allow");
+ expect(bash("cat > run.sh < run.sh <<-SH\n\trg -n auth src\n\tSH")).toBe("allow");
+ expect(bash('cat >> notes.md <<"MD"\ngrep -rn auth src is the old way\nMD')).toBe("allow");
+ });
+
+ it("still reads the commands around the body", () => {
+ expect(bash("cat <<'EOF' > /dev/null\nx\nEOF\nrg -n auth src")).toBe("deny");
+ expect(bash("rg -n auth src\ncat > run.sh <<'SH'\nls\nSH")).toBe("deny");
+ });
+
+ it("allows a search line quoted into another command", () => {
+ expect(bash("echo 'rg -n auth src'")).toBe("allow");
+ expect(bash("git commit -m 'prefer sql over rg -n auth src'")).toBe("allow");
+ });
+ });
+
+ // Each of these hid the launcher from the scan: a path, a wrapper that takes
+ // its own operand, or git's global flags in front of the subcommand.
+ describe("launcher shapes", () => {
+ it("denies through git's global flags", () => {
+ expect(bash("git -C . grep auth")).toBe("deny");
+ expect(bash("git --no-pager grep auth")).toBe("deny");
+ expect(bash("git -c core.pager=cat grep auth")).toBe("deny");
+ expect(bash("git -C src grep auth")).toBe("deny");
+ });
+
+ it("allows git grep aimed at another tree", () => {
+ expect(bash("git -C /etc grep auth")).toBe("allow");
+ });
+
+ it("denies behind timeout, an absolute path, command, and xargs", () => {
+ expect(bash("timeout 5 rg -n auth src")).toBe("deny");
+ expect(bash("timeout 30s rg -n auth src")).toBe("deny");
+ expect(bash("/usr/bin/rg -n auth src")).toBe("deny");
+ expect(bash("command grep -rn auth src")).toBe("deny");
+ expect(bash("xargs -a /dev/null rg -n auth src")).toBe("deny");
+ expect(bash("nice -n 5 rg -n auth src")).toBe("deny");
+ });
+
+ it("leaves a launcher lookup alone", () => {
+ expect(bash("command -v rg")).toBe("allow");
+ expect(bash("which grep")).toBe("allow");
+ });
+ });
+
+ describe("index state short of fully live", () => {
+ it("allows while vectors are still building", () => {
+ writeIndex(indexDir, { manifest: { root: repo, vectors: "building" } });
+ expect(bash("grep -n auth src/a.rs")).toBe("allow");
+ });
+
+ it("allows when the index is partial (files over the cap)", () => {
+ writeIndex(indexDir, { manifest: { root: repo, truncatedFiles: 3, maxFiles: 2 } });
+ expect(bash("grep -n auth src/a.rs")).toBe("allow");
+ });
+
+ it("allows when the manifest is orphaned by a missing table", () => {
+ rmSync(join(indexDir, TABLE_DIR), { recursive: true, force: true });
+ expect(bash("grep -n auth src/a.rs")).toBe("allow");
+ });
+
+ it("allows when filestate is missing", () => {
+ rmSync(join(indexDir, "filestate.json"), { force: true });
+ expect(bash("grep -n auth src/a.rs")).toBe("allow");
+ });
+
+ it("allows when filestate is unparseable or records no files", () => {
+ writeFileSync(join(indexDir, "filestate.json"), "{ not json");
+ expect(bash("grep -n auth src/a.rs")).toBe("allow");
+ writeIndex(indexDir, { manifest: { root: repo }, files: [] });
+ expect(bash("grep -n auth src/a.rs")).toBe("allow");
+ });
+
+ it("allows when there is no index at all", () => {
+ rmSync(indexDir, { recursive: true, force: true });
+ expect(bash("grep -n auth src/a.rs")).toBe("allow");
+ });
+
+ it("allows on an unparseable payload", () => {
+ const run = spawnSync(process.execPath, [HOOK], { input: "{ not json", encoding: "utf8", env: BASE_ENV });
+ expect(run.status).toBe(0);
+ expect(run.stdout.trim()).toBe("");
+ });
+ });
+
+ describe("the CX_NO_ENFORCE kill switch", () => {
+ const off = { CX_NO_ENFORCE: "1" };
+
+ it("allows a grep that would otherwise be denied", () => {
+ expect(bash("grep -n auth src/a.rs", off)).toBe("allow");
+ expect(grepTool({ pattern: "auth", path: "src" }, off)).toBe("allow");
+ });
+
+ it("allows the legacy search tool, which used to be denied above it", () => {
+ const payload = { hook_event_name: "PreToolUse", tool_name: "mcp__code-context__search", cwd: repo, tool_input: { query: "auth" } };
+ expect(hook(payload, off).decision).toBe("allow");
+ expect(hook(payload).decision).toBe("deny");
+ });
+
+ it("still describes the index at SessionStart", () => {
+ const note = hook({ hook_event_name: "SessionStart", cwd: repo }, off).context;
+ expect(note).toContain("hybrid_search");
+ expect(note).not.toContain("are disabled");
+ });
+ });
+
+ describe("the Grep tool", () => {
+ it("denies an indexed path, and a repo-wide search with no path", () => {
+ expect(grepTool({ pattern: "auth", path: "src" })).toBe("deny");
+ expect(grepTool({ pattern: "auth" })).toBe("deny");
+ });
+
+ it("allows an uncovered path", () => {
+ expect(grepTool({ pattern: "Foo", path: "vendor" })).toBe("allow");
+ expect(grepTool({ pattern: "1", path: "data.csv" })).toBe("allow");
+ });
+
+ it("allows a glob that matches nothing indexed, denies one that matches", () => {
+ expect(grepTool({ pattern: "auth", glob: "*.py" })).toBe("allow");
+ expect(grepTool({ pattern: "auth", glob: "src/**/*.go" })).toBe("allow");
+ expect(grepTool({ pattern: "auth", glob: "*.rs" })).toBe("deny");
+ expect(grepTool({ pattern: "auth", glob: "src/*.rs" })).toBe("deny");
+ });
+ });
+
+ describe("the repo root the manifest records", () => {
+ it("enforces on an index kept outside the repo (CX_INDEX_DIR)", () => {
+ const external = tempDir("cx-enforce-index-");
+ const elsewhere = tempDir("cx-enforce-cwd-");
+ rmSync(indexDir, { recursive: true, force: true });
+ writeIndex(external, { manifest: { root: repo } });
+ const env = { CX_INDEX_DIR: external };
+ const payload = (command: string) => ({
+ hook_event_name: "PreToolUse",
+ tool_name: "Bash",
+ cwd: elsewhere,
+ tool_input: { command },
+ });
+ // Without manifest.root the hook would call cwd the root, read the repo
+ // as a sibling "outside the repo", and allow both of these.
+ expect(hook(payload(`grep -n auth ${join(repo, "src", "a.rs")}`), env).decision).toBe("deny");
+ expect(hook(payload(`grep -n auth ${join(repo, "data.csv")}`), env).decision).toBe("allow");
+ });
+
+ it("falls back to the walked-up index directory when the manifest has no root", () => {
+ writeIndex(indexDir, {});
+ expect(bash("grep -n auth src/a.rs")).toBe("deny");
+ expect(bash("grep -n 1 data.csv")).toBe("allow");
+ });
+
+ it("prefers the walked-up root over a stale one (a copied checkout)", () => {
+ // What `cp -a repo repo2` leaves behind: an index found inside repo2
+ // whose manifest still names repo. Trusting that root resolves every
+ // target "outside the repo" and silently stops enforcing, while sql
+ // answers repo2's queries fine.
+ const copy = buildTree();
+ const copyIndex = join(copy, ".infino");
+ writeIndex(copyIndex, { manifest: { root: repo } });
+ const at = (command: string) =>
+ hook({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: copy, tool_input: { command } }).decision;
+ expect(at("grep -n auth src/a.rs")).toBe("deny");
+ expect(at("grep -rn auth src")).toBe("deny");
+ expect(at(`grep -n auth ${join(copy, "src", "a.rs")}`)).toBe("deny");
+ expect(at("grep -n 1 data.csv")).toBe("allow");
+ // The same stale root loses when CX_INDEX_DIR names the copy's own index.
+ const env = { CX_INDEX_DIR: copyIndex };
+ expect(
+ hook({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: copy, tool_input: { command: "grep -n auth src/a.rs" } }, env)
+ .decision,
+ ).toBe("deny");
+ });
+ });
+
+ describe("round-2 hardening (verifier findings N1-N6)", () => {
+ it("answers a wildcard-flooded glob promptly instead of backtracking (N1)", () => {
+ // 40 stars used to compile to stacked `.*`s and wedge past the hook
+ // timeout; the vitest case timeout is the regression tripwire here.
+ expect(bash(`rg -n auth ${"*".repeat(40)}.ts`)).toBe("allow");
+ expect(grepTool({ pattern: "auth", glob: `${"*".repeat(40)}.rs` })).toBe("deny");
+ });
+
+ it("reads a bare operand glob the way the shell does, not against basenames (N2)", () => {
+ // The shell expands `*.rs` in the cwd before rg runs; no root-level file
+ // matches, so nothing indexed is searched. Basename matching read it as
+ // "src/a.rs, somewhere" and denied a search sql could not answer.
+ expect(bash("rg -n zzz *.rs")).toBe("allow");
+ expect(bash("rg -n zzz src/*.rs")).toBe("deny");
+ // The Grep tool's glob IS recursive; basename matching is right there.
+ expect(grepTool({ pattern: "zzz", glob: "*.rs" })).toBe("deny");
+ });
+
+ it("sees through shell control words and group openers (N3)", () => {
+ expect(bash("if rg -q auth src/a.rs; then echo y; fi")).toBe("deny");
+ expect(bash("while rg -q auth src/a.rs; do :; done")).toBe("deny");
+ expect(bash("for f in 1; do rg -n auth src/a.rs; done")).toBe("deny");
+ expect(bash("{ rg -n auth src/a.rs; }")).toBe("deny");
+ expect(bash("( rg -n auth src/a.rs )")).toBe("deny");
+ expect(bash("(rg -n auth src/a.rs)")).toBe("deny");
+ expect(bash("! rg -q auth src/a.rs")).toBe("deny");
+ });
+
+ it("treats backgrounding as a launch and redirections as text (N3)", () => {
+ expect(bash("rg -n auth src/a.rs &")).toBe("deny");
+ expect(bash("rg -n auth src/a.rs & wait")).toBe("deny");
+ // `>&` is a redirection, not a control operator - still one segment.
+ expect(bash("cargo build 2>&1 | grep error")).toBe("allow");
+ expect(bash("echo done & wait")).toBe("allow");
+ });
+
+ it("fails open on a wholly pre-upgrade filestate, covered on a mixed one (N5)", () => {
+ // No entry anywhere carries a count: the state cannot distinguish a
+ // chunk-less file, so nothing is denied until a rebuild stamps counts.
+ writeIndex(indexDir, {
+ manifest: { root: repo },
+ files: INDEXED.map((f) => [f, undefined] as [string, undefined]),
+ });
+ expect(bash("rg -n auth src/a.rs")).toBe("allow");
+ // One stamped entry makes it a mixed (mid-upgrade) state: the unstamped
+ // rest keeps the back-compatible covered reading.
+ writeIndex(indexDir, {
+ manifest: { root: repo },
+ files: [["src/a.rs", undefined], "src/auth.rs"],
+ });
+ expect(bash("rg -n auth src/a.rs")).toBe("deny");
+ });
+
+ it("steps over env -i and the other value-less wrapper flags (N6)", () => {
+ expect(bash("env -i rg -n auth src/a.rs")).toBe("deny");
+ expect(bash("nohup rg -n auth src/a.rs")).toBe("deny");
+ expect(bash("stdbuf -o0 rg -n auth src/a.rs")).toBe("deny");
+ // xargs -I really does take a value; its launcher is still found.
+ expect(bash("xargs -I {} rg -n auth src/a.rs")).toBe("deny");
+ // env's -u takes a value too - the launcher must not be eaten.
+ expect(bash("env -u PAGER rg -n auth src/a.rs")).toBe("deny");
+ });
+ });
+
+ describe("SessionStart context", () => {
+ it("announces enforcement when the index is fully live", () => {
+ const note = hook({ hook_event_name: "SessionStart", cwd: repo }).context;
+ expect(note).toContain("are disabled");
+ expect(note).toContain("CX_GREP_FALLBACK=1");
+ });
+
+ it("says grep stays enabled while the index is not live", () => {
+ writeIndex(indexDir, { manifest: { root: repo, vectors: "building" } });
+ expect(hook({ hook_event_name: "SessionStart", cwd: repo }).context).toContain("grep stays enabled");
+ });
+ });
+});
+
+// The hand-written fixtures above pin the hook's rules; this one pins the
+// agreement they assume - that what the indexer records is what the hook reads.
+// A real build over a tree the indexer partly skips is the case the old
+// re-derived coverage got wrong, so it is worth the one real index a run costs.
+const fakeEmbedder: Embedder = {
+ embed: async (texts) => texts.map((t) => new Array(16).fill(t.length / 100)),
+ dim: async () => 16,
+ provider: "fake",
+ model: "fake-16d",
+};
+
+/** A `.py` the walker takes and the chunker then refuses: an indexable
+ * extension over bytes with a NUL in them. */
+const NUL_MODULE = Buffer.from("print('x')\u0000binary tail\n", "latin1");
+
+describe("against an index the indexer actually built", () => {
+ let root: string;
+ let dir: string;
+ let db: ReturnType;
+
+ /** Paths the table actually holds rows for - the ground truth the hook's
+ * covered set has to equal. (Read as a GROUP BY: the binding cannot decode
+ * a bare DISTINCT projection.) */
+ const tablePaths = (): string[] =>
+ (db.querySql("SELECT path, COUNT(*) AS n FROM chunks GROUP BY path") as Array<{ path: string }>)
+ .map((r) => r.path)
+ .sort();
+
+ const bash = (command: string) =>
+ hook({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: root, tool_input: { command } }).decision;
+
+ /** Every path the index fingerprinted, split by what the hook decides for
+ * it. A denied path is one the hook claims sql can answer. */
+ const coveredByHook = (): string[] =>
+ Object.keys(readFileState(dir)?.files ?? {})
+ .filter((p) => bash(`grep -n zzz ${p}`) === "deny")
+ .sort();
+
+ beforeAll(async () => {
+ root = mkdtempSync(join(tmpdir(), "cx-enforce-real-"));
+ mkdirSync(join(root, "src"), { recursive: true });
+ mkdirSync(join(root, "pkg"), { recursive: true });
+ writeFileSync(join(root, "src", "alpha.ts"), "export function alphaThing() { return 'quokka'; }\n");
+ writeFileSync(join(root, "data.csv"), "a,b\n1,2\n");
+ writeFileSync(join(root, "package-lock.json"), '{"name":"x","lockfileVersion":3}\n');
+ // The two shapes filestate records but the table has no row for.
+ writeFileSync(join(root, "pkg", "__init__.py"), "");
+ writeFileSync(join(root, "pkg", "blob.py"), NUL_MODULE);
+ writeFileSync(join(root, "pkg", "thing.py"), "def thing():\n return 'wombat'\n");
+ dir = join(root, ".infino");
+ db = connect(dir);
+ await indexRepo({ root, db, indexDirPath: dir, embedder: fakeEmbedder });
+ });
+
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
+
+ it("records the repo root in the manifest", () => {
+ expect(readManifest(join(root, ".infino"))?.root).toBe(root);
+ });
+
+ it("denies what the build indexed and allows what it skipped", () => {
+ expect(bash("grep -n alphaThing src/alpha.ts")).toBe("deny");
+ expect(bash("grep -n 1 data.csv")).toBe("allow");
+ expect(bash("grep -n lockfileVersion package-lock.json")).toBe("allow");
+ });
+
+ it("records a row count for every fingerprinted file, zero included", () => {
+ const files = readFileState(dir)?.files ?? {};
+ expect(files["pkg/__init__.py"]?.chunks).toBe(0);
+ expect(files["pkg/blob.py"]?.chunks).toBe(0);
+ expect(files["pkg/thing.py"]?.chunks).toBeGreaterThan(0);
+ // Fingerprinted-but-rowless files stay in the state (dropping them would
+ // make every later sync re-hash them), which is why the count is the only
+ // thing separating the state from the table.
+ expect(Object.keys(files).sort()).not.toEqual(tablePaths());
+ });
+
+ it("covers exactly the paths the table holds rows for", () => {
+ expect(coveredByHook()).toEqual(tablePaths());
+ // Which is to say: the rowless siblings are grep-able, the real one is not.
+ expect(bash("grep -n thing pkg/thing.py")).toBe("deny");
+ expect(bash("grep -n x pkg/__init__.py")).toBe("allow");
+ expect(bash("grep -n x pkg/blob.py")).toBe("allow");
+ expect(bash("grep -rn thing pkg")).toBe("deny");
+ });
+
+ it("keeps the counts (and so the covered set) right across a sync", async () => {
+ writeFileSync(join(root, "pkg", "late.py"), "def late():\n return 'axolotl'\n");
+ writeFileSync(join(root, "pkg", "late_empty.py"), "");
+ writeFileSync(join(root, "pkg", "late_blob.py"), NUL_MODULE);
+ const outcome = await syncRepo({ root, db, indexDirPath: dir, embedder: fakeEmbedder });
+ expect(outcome.action).toBe("synced");
+
+ const files = readFileState(dir)?.files ?? {};
+ expect(files["pkg/late.py"]?.chunks).toBeGreaterThan(0);
+ expect(files["pkg/late_empty.py"]?.chunks).toBe(0);
+ expect(files["pkg/late_blob.py"]?.chunks).toBe(0);
+ // The full-build entries survive the sync with their counts intact.
+ expect(files["pkg/__init__.py"]?.chunks).toBe(0);
+ expect(coveredByHook()).toEqual(tablePaths());
+ expect(bash("grep -n late pkg/late.py")).toBe("deny");
+ expect(bash("grep -n x pkg/late_empty.py")).toBe("allow");
+ expect(bash("grep -n x pkg/late_blob.py")).toBe("allow");
+ });
+});
diff --git a/test/install.test.ts b/test/install.test.ts
new file mode 100644
index 0000000..a81ba86
--- /dev/null
+++ b/test/install.test.ts
@@ -0,0 +1,572 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright The Infino Authors
+//
+// `cx install` edits a settings file the user shares with every other hook
+// they own, so most of what matters here is what it must NOT do: stack a
+// second copy of itself on reinstall, drop a foreign hook (including one that
+// merely shares an entry with ours), lose an unrelated top-level key, truncate
+// the file if it dies mid-write, or write machine-specific paths into a
+// project's shareable settings file. --uninstall has the mirror obligations,
+// plus leaving no empty "hooks" husk when ours were the only entries, and
+// leaving the hook script in place while a sibling settings file still runs it.
+//
+// The load-bearing assertion is the hook command. It has to carry the
+// absolute `process.execPath` and the absolute hook path: a bare "node"
+// resolves against the client's PATH, and a client launched from a GUI often
+// has none - the hook then fails silently, which is indistinguishable from
+// enforcement that simply does not work. An unresolvable home directory is
+// the same failure by another route, so it has to be an error.
+//
+// Every case points HOME and --settings at a temp directory, so the real
+// ~/.claude is never touched.
+import { execFileSync } from "node:child_process";
+import {
+ chmodSync,
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { basename, dirname, isAbsolute, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { InstallError, installCmd } from "../src/commands/install-cmd.js";
+
+/** Where install puts the hook, relative to the settings file's directory. */
+const HOOK_REL_PATH = join("hooks", "cx-deny-grep.mjs");
+
+/** The packaged hook install copies from - the source of truth for content. */
+const PACKAGED_HOOK = new URL("../hooks/deny-grep.mjs", import.meta.url);
+
+/** The PreToolUse matcher the entry must carry (mirrors hooks/hooks.json):
+ * the built-in search tools plus the legacy code-context `search` tool. */
+const PRE_TOOL_MATCHER = "Grep|Bash|mcp__.*code[-_]context.*__search";
+
+/** `"" ""` - both halves absolute, quoted, nothing else. */
+const COMMAND_SHAPE = /^"([^"]+)" "([^"]+)"$/;
+
+interface HookCommand {
+ type: string;
+ command: string;
+}
+
+interface HookEntry {
+ matcher?: string;
+ hooks: HookCommand[];
+}
+
+type Settings = Record & { hooks?: Record };
+
+/** Hooks that belong to somebody else: one on an event we never touch, one
+ * sharing SessionStart with ours. Both must survive install and uninstall. */
+const FOREIGN_POST_TOOL: HookEntry = {
+ matcher: "Write|Edit",
+ hooks: [{ type: "command", command: "node /home/someone/.claude/hooks/prettier.mjs" }],
+};
+const FOREIGN_SESSION_START: HookEntry = {
+ hooks: [{ type: "command", command: "/usr/local/bin/greet-me" }],
+};
+
+/** A hook of the user's that sits inside the same entry as ours - the entry is
+ * shared, so ownership has to be decided per hook and not per entry. */
+const AUDIT_HOOK: HookCommand = {
+ type: "command",
+ command: "node /home/someone/.claude/hooks/audit-log.mjs",
+};
+
+/** Name the dotfile-managed copy of the settings carries. Deliberately not
+ * `settings.json`: the temp file and the rename have to follow the link to
+ * wherever it points, not reuse the name of the link. */
+const TRACKED_SETTINGS = "claude-settings.json";
+
+/** Modes for the settings directory: one the user cannot write - enough to
+ * fail the settings replace after the hook has already been copied in - and
+ * the one it is restored to so the sandbox can be removed. */
+const READ_ONLY_DIR = 0o500;
+const WRITABLE_DIR = 0o700;
+
+/** A wrapper of the user's that merely names our script on its command line.
+ * Matching on the basename would delete it; matching on the resolved path we
+ * wrote does not. */
+const WRAPPER_HOOK: HookEntry = {
+ hooks: [
+ { type: "command", command: "node /home/someone/my-wrapper.mjs --hook cx-deny-grep.mjs" },
+ ],
+};
+
+describe("cx install", () => {
+ let dir: string;
+ let settingsPath: string;
+ let hookPath: string;
+ let realHome: string | undefined;
+
+ const read = (path = settingsPath): Settings =>
+ JSON.parse(readFileSync(path, "utf8")) as Settings;
+ const seed = (settings: Settings): void => seedText(JSON.stringify(settings, null, 2) + "\n");
+ const seedText = (text: string): void => {
+ mkdirSync(dirname(settingsPath), { recursive: true });
+ writeFileSync(settingsPath, text);
+ };
+ /** `settings.json` as a symlink into a version-controlled directory - the
+ * shape every dotfile manager (stow, chezmoi, a hand-rolled Makefile)
+ * leaves behind. Returns the link target, which is the file that has to
+ * receive the merged settings. */
+ const seedSymlinked = (settings: Settings): string => {
+ const tracked = join(dir, "dotfiles", TRACKED_SETTINGS);
+ mkdirSync(dirname(tracked), { recursive: true });
+ writeFileSync(tracked, JSON.stringify(settings, null, 2) + "\n");
+ mkdirSync(dirname(settingsPath), { recursive: true });
+ symlinkSync(tracked, settingsPath);
+ return tracked;
+ };
+ /** Run `body` with the settings directory read-only, so the settings replace
+ * fails where a real EACCES would: after the hook script is on disk. */
+ const withUnwritableDir = (body: () => void): void => {
+ chmodSync(dirname(settingsPath), READ_ONLY_DIR);
+ try {
+ body();
+ } finally {
+ chmodSync(dirname(settingsPath), WRITABLE_DIR);
+ }
+ };
+ /** The hook command install writes, as a pre-existing entry would hold it. */
+ const ourHook = (): HookCommand => ({
+ type: "command",
+ command: `"${process.execPath}" "${hookPath}"`,
+ });
+ const runsOurs = (entry: HookEntry): boolean =>
+ entry.hooks.some((hook) => hook.command.endsWith(`"${hookPath}"`));
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "cx-install-"));
+ // Point HOME at the sandbox: install refuses a .claude directory that is
+ // not the user's own, and every case here targets /.claude.
+ realHome = process.env.HOME;
+ process.env.HOME = dir;
+ settingsPath = join(dir, ".claude", "settings.json");
+ hookPath = join(dir, ".claude", HOOK_REL_PATH);
+ });
+ afterEach(() => {
+ if (realHome === undefined) delete process.env.HOME;
+ else process.env.HOME = realHome;
+ vi.restoreAllMocks();
+ rmSync(dir, { recursive: true, force: true });
+ });
+
+ describe("install", () => {
+ it("creates the settings file with both events and the packaged hook", () => {
+ expect(existsSync(settingsPath)).toBe(false);
+ installCmd({ settings: settingsPath });
+
+ const settings = read();
+ expect(settings.hooks?.SessionStart).toHaveLength(1);
+ expect(settings.hooks?.PreToolUse).toHaveLength(1);
+ expect(settings.hooks?.PreToolUse?.[0].matcher).toBe(PRE_TOOL_MATCHER);
+ expect(settings.hooks?.SessionStart?.[0].matcher).toBeUndefined();
+
+ // The hook lands beside the settings file and is a byte copy of the
+ // packaged one - a stale or rewritten copy would enforce old rules.
+ expect(existsSync(hookPath)).toBe(true);
+ expect(readFileSync(hookPath)).toEqual(readFileSync(PACKAGED_HOOK));
+ });
+
+ it("embeds the absolute node binary and the absolute hook path", () => {
+ installCmd({ settings: settingsPath });
+
+ const settings = read();
+ const commands = [
+ settings.hooks?.SessionStart?.[0].hooks[0],
+ settings.hooks?.PreToolUse?.[0].hooks[0],
+ ];
+ for (const hook of commands) {
+ expect(hook?.type).toBe("command");
+ const parts = COMMAND_SHAPE.exec(hook?.command ?? "");
+ expect(parts).not.toBeNull();
+ const [, node, script] = parts ?? [];
+ expect(node).toBe(process.execPath);
+ expect(script).toBe(hookPath);
+ expect(isAbsolute(node)).toBe(true);
+ expect(isAbsolute(script)).toBe(true);
+ // A bare interpreter name is the silent-failure mode we guard against.
+ expect(hook?.command).not.toMatch(/^"?node"?\s/);
+ }
+ });
+
+ it("defaults to the settings file in the home directory", () => {
+ installCmd({});
+
+ expect(existsSync(settingsPath)).toBe(true);
+ expect(read().hooks?.PreToolUse).toHaveLength(1);
+ expect(existsSync(hookPath)).toBe(true);
+ });
+
+ it("installs twice without stacking duplicate entries", () => {
+ installCmd({ settings: settingsPath });
+ installCmd({ settings: settingsPath });
+
+ const settings = read();
+ expect(settings.hooks?.SessionStart).toHaveLength(1);
+ expect(settings.hooks?.PreToolUse).toHaveLength(1);
+ });
+
+ it("keeps foreign hooks, on our events and on events we never touch", () => {
+ seed({ hooks: { PostToolUse: [FOREIGN_POST_TOOL], SessionStart: [FOREIGN_SESSION_START] } });
+ installCmd({ settings: settingsPath });
+
+ const settings = read();
+ expect(settings.hooks?.PostToolUse).toEqual([FOREIGN_POST_TOOL]);
+ expect(settings.hooks?.SessionStart).toContainEqual(FOREIGN_SESSION_START);
+ expect(settings.hooks?.SessionStart).toHaveLength(2);
+ expect(settings.hooks?.PreToolUse).toHaveLength(1);
+ });
+
+ it("keeps a foreign hook that shares an entry with ours", () => {
+ // The user added their audit hook next to ours, in one entry with their
+ // own matcher. Reinstalling replaces our hook, not their entry.
+ seed({ hooks: { PreToolUse: [{ matcher: "Bash", hooks: [AUDIT_HOOK, ourHook()] }] } });
+ installCmd({ settings: settingsPath });
+
+ const entries = read().hooks?.PreToolUse ?? [];
+ expect(entries).toHaveLength(2);
+ const shared = entries.find((entry) => entry.matcher === "Bash");
+ expect(shared?.hooks).toEqual([AUDIT_HOOK]);
+ const ours = entries.filter(runsOurs);
+ expect(ours).toHaveLength(1);
+ expect(ours[0].matcher).toBe(PRE_TOOL_MATCHER);
+ });
+
+ it("leaves a wrapper of the user's that merely names our script", () => {
+ seed({ hooks: { SessionStart: [WRAPPER_HOOK] } });
+ installCmd({ settings: settingsPath });
+ expect(read().hooks?.SessionStart).toContainEqual(WRAPPER_HOOK);
+
+ installCmd({ uninstall: true, settings: settingsPath });
+ expect(read().hooks?.SessionStart).toEqual([WRAPPER_HOOK]);
+ });
+
+ it("leaves unrelated top-level settings keys untouched", () => {
+ const permissions = { allow: ["Bash(git status)"], deny: [] };
+ const env = { CX_MAX_FILE_BYTES: "2097152" };
+ seed({ permissions, env, model: "opus" });
+ installCmd({ settings: settingsPath });
+
+ const settings = read();
+ expect(settings.permissions).toEqual(permissions);
+ expect(settings.env).toEqual(env);
+ expect(settings.model).toBe("opus");
+ });
+
+ it("replaces the settings file atomically and leaves no temp file", () => {
+ seed({ model: "opus" });
+ const before = statSync(settingsPath).ino;
+ installCmd({ settings: settingsPath });
+
+ // A rename swaps the inode; an in-place truncate-then-write keeps it and
+ // is exactly the window where a crash costs the user every setting.
+ expect(statSync(settingsPath).ino).not.toBe(before);
+ expect(readdirSync(dirname(settingsPath)).sort()).toEqual(["hooks", "settings.json"]);
+
+ installCmd({ uninstall: true, settings: settingsPath });
+ expect(readdirSync(dirname(settingsPath)).sort()).toEqual(["hooks", "settings.json"]);
+ });
+
+ it("writes through a symlinked settings file and leaves the link a link", () => {
+ const tracked = seedSymlinked({ model: "opus" });
+ installCmd({ settings: settingsPath });
+
+ // Renaming onto the link replaces it with a regular file: the tracked
+ // copy keeps the old bytes and the user's settings silently stop
+ // applying, which is the same as losing them.
+ expect(lstatSync(settingsPath).isSymbolicLink()).toBe(true);
+ const settings = read(tracked);
+ expect(settings.hooks?.PreToolUse).toHaveLength(1);
+ expect(settings.model).toBe("opus");
+ // The temp file belongs beside the real target, and must not survive it.
+ expect(readdirSync(dirname(tracked))).toEqual([TRACKED_SETTINGS]);
+ expect(readdirSync(dirname(settingsPath)).sort()).toEqual(["hooks", "settings.json"]);
+ });
+
+ it("creates the target of a settings symlink that does not point at a file yet", () => {
+ // The link is in place but the dotfiles repo is not checked out; writing
+ // a regular file at the link's own path would break the setup the moment
+ // it is.
+ const tracked = join(dir, "dotfiles", TRACKED_SETTINGS);
+ mkdirSync(dirname(settingsPath), { recursive: true });
+ symlinkSync(tracked, settingsPath);
+ installCmd({ settings: settingsPath });
+
+ expect(lstatSync(settingsPath).isSymbolicLink()).toBe(true);
+ expect(read(tracked).hooks?.PreToolUse).toHaveLength(1);
+ expect(readdirSync(dirname(tracked))).toEqual([TRACKED_SETTINGS]);
+ });
+
+ it("removes the hook it just copied when the settings write fails", () => {
+ seed({ model: "opus" });
+ mkdirSync(dirname(hookPath), { recursive: true });
+ withUnwritableDir(() => {
+ expect(() => installCmd({ settings: settingsPath })).toThrow();
+ });
+
+ // The script is copied before the settings that reference it, so an
+ // aborted write leaves a file nothing runs.
+ expect(existsSync(hookPath)).toBe(false);
+ expect(read()).toEqual({ model: "opus" });
+ });
+
+ it("keeps a hook script from an earlier install when the write fails", () => {
+ installCmd({ settings: settingsPath });
+ const copied = readFileSync(hookPath);
+ withUnwritableDir(() => {
+ expect(() => installCmd({ settings: settingsPath })).toThrow();
+ });
+
+ // This one is still wired up by the settings written before; deleting it
+ // would break every tool call in the next session.
+ expect(readFileSync(hookPath)).toEqual(copied);
+ expect(readFileSync(settingsPath, "utf8")).toContain(hookPath);
+ });
+
+ it("reports Claude Code and the settings file it wrote", () => {
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
+ installCmd({ settings: settingsPath });
+
+ const out = log.mock.calls.map((args) => args.join(" ")).join("\n");
+ expect(out).toContain("Claude Code");
+ expect(out).toContain(settingsPath);
+ expect(out).toContain(hookPath);
+ });
+ });
+
+ describe("target resolution", () => {
+ it("refuses to install when the home directory is not absolute", () => {
+ // os.homedir() answers "" under `env -i` and some CI runners; joining
+ // that writes .claude into the current working directory instead.
+ const cwdClaude = join(process.cwd(), ".claude");
+ const existed = existsSync(cwdClaude);
+
+ process.env.HOME = "";
+ expect(() => installCmd({})).toThrow(InstallError);
+ expect(() => installCmd({})).toThrow(/home directory/i);
+ process.env.HOME = "relative/home";
+ expect(() => installCmd({})).toThrow(/home directory/i);
+
+ expect(existsSync(cwdClaude)).toBe(existed);
+ });
+
+ it("refuses a project-scoped .claude settings file", () => {
+ const project = join(dir, "repo", ".claude", "settings.json");
+
+ expect(() => installCmd({ settings: project })).toThrow(InstallError);
+ expect(() => installCmd({ settings: project })).toThrow(/project-scoped/);
+ // Nothing written, not even the directory: the shareable file would
+ // carry this machine's node path and a hook copy inside the repo.
+ expect(existsSync(dirname(project))).toBe(false);
+ });
+
+ it("accepts the home settings file reached through a symlinked HOME", () => {
+ // A relocated or symlinked home (/home/you -> /mnt/home/you, macOS
+ // /tmp -> /private/tmp) still names the user's own .claude directory,
+ // so scope has to be decided on resolved paths, not on the spelling.
+ const home = join(dir, "home");
+ mkdirSync(join(home, ".claude"), { recursive: true });
+ const linked = join(dir, "home-link");
+ symlinkSync(home, linked);
+ process.env.HOME = linked;
+ const target = join(home, ".claude", "settings.json");
+
+ expect(() => installCmd({ settings: target })).not.toThrow();
+ expect(read(target).hooks?.PreToolUse).toHaveLength(1);
+ });
+
+ it("installs into a project-scoped file under --force, and uninstalls without it", () => {
+ const project = join(dir, "repo", ".claude", "settings.json");
+ installCmd({ settings: project, force: true });
+ expect(read(project).hooks?.PreToolUse).toHaveLength(1);
+
+ // Undoing a --force install must not need --force, or it would be a
+ // one-way door.
+ installCmd({ uninstall: true, settings: project });
+ expect(readFileSync(project, "utf8")).not.toContain("cx-deny-grep");
+ expect(existsSync(join(dirname(project), HOOK_REL_PATH))).toBe(false);
+ });
+ });
+
+ describe("unreadable settings", () => {
+ /** A settings file we refuse must come back byte-identical, and no hook
+ * may be copied in on the way. */
+ const expectRefusal = (text: string, message: RegExp): void => {
+ seedText(text);
+ expect(() => installCmd({ settings: settingsPath })).toThrow(message);
+ expect(() => installCmd({ uninstall: true, settings: settingsPath })).toThrow(message);
+ expect(readFileSync(settingsPath, "utf8")).toBe(text);
+ expect(existsSync(hookPath)).toBe(false);
+ };
+
+ it("refuses a settings file that is not valid JSON", () => {
+ expectRefusal('{\n // JSONC is not JSON\n "hooks": {}\n}\n', /not valid JSON/);
+ });
+
+ it("refuses a settings file that does not hold an object", () => {
+ expectRefusal("null\n", /holds null, not a JSON object/);
+ });
+
+ it("refuses a hooks block that is not an object of events", () => {
+ expectRefusal('{\n "hooks": []\n}\n', /"hooks".*is an array/);
+ });
+
+ it("refuses an event whose entries are not an array", () => {
+ expectRefusal('{\n "hooks": {\n "PreToolUse": "Grep"\n }\n}\n', /PreToolUse.*a string/);
+ });
+ });
+
+ describe("uninstall", () => {
+ it("removes our entries and the copied hook file", () => {
+ installCmd({ settings: settingsPath });
+ installCmd({ uninstall: true, settings: settingsPath });
+
+ expect(existsSync(hookPath)).toBe(false);
+ expect(readFileSync(settingsPath, "utf8")).not.toContain("cx-deny-grep");
+ });
+
+ it("leaves no empty hooks husk when ours were the only entries", () => {
+ installCmd({ settings: settingsPath });
+ installCmd({ uninstall: true, settings: settingsPath });
+
+ expect(read().hooks).toBeUndefined();
+ });
+
+ it("keeps the hooks block and its foreign entries when others remain", () => {
+ seed({ hooks: { PostToolUse: [FOREIGN_POST_TOOL], SessionStart: [FOREIGN_SESSION_START] } });
+ installCmd({ settings: settingsPath });
+ installCmd({ uninstall: true, settings: settingsPath });
+
+ const settings = read();
+ expect(settings.hooks?.PostToolUse).toEqual([FOREIGN_POST_TOOL]);
+ expect(settings.hooks?.SessionStart).toEqual([FOREIGN_SESSION_START]);
+ expect(settings.hooks?.PreToolUse).toBeUndefined();
+ });
+
+ it("keeps the other hooks of an entry it shares with us", () => {
+ seed({ hooks: { PreToolUse: [{ matcher: "Bash", hooks: [AUDIT_HOOK, ourHook()] }] } });
+ installCmd({ uninstall: true, settings: settingsPath });
+
+ const entries = read().hooks?.PreToolUse ?? [];
+ expect(entries).toEqual([{ matcher: "Bash", hooks: [AUDIT_HOOK] }]);
+ });
+
+ it("keeps the hook file while a sibling settings file still runs it", () => {
+ // settings.json and settings.local.json share one hooks/ directory, so
+ // the script is shared too: deleting it out from under the survivor
+ // turns every tool call into MODULE_NOT_FOUND.
+ const localPath = join(dir, ".claude", "settings.local.json");
+ installCmd({ settings: settingsPath });
+ installCmd({ settings: localPath });
+
+ installCmd({ uninstall: true, settings: settingsPath });
+ expect(existsSync(hookPath)).toBe(true);
+ expect(readFileSync(localPath, "utf8")).toContain(hookPath);
+
+ installCmd({ uninstall: true, settings: localPath });
+ expect(existsSync(hookPath)).toBe(false);
+ });
+
+ it("keeps the hook file while a custom-named settings file still runs it", () => {
+ // --settings takes any path, so the survivor need not be named
+ // settings.local.json for its hook command to be live.
+ const custom = join(dirname(settingsPath), "my-settings.json");
+ installCmd({ settings: settingsPath });
+ installCmd({ settings: custom });
+
+ installCmd({ uninstall: true, settings: settingsPath });
+ expect(existsSync(hookPath)).toBe(true);
+ expect(readFileSync(custom, "utf8")).toContain(hookPath);
+
+ installCmd({ uninstall: true, settings: custom });
+ expect(existsSync(hookPath)).toBe(false);
+ });
+
+ it("writes through a symlinked settings file and leaves the link a link", () => {
+ const tracked = seedSymlinked({ model: "opus" });
+ installCmd({ settings: settingsPath });
+ installCmd({ uninstall: true, settings: settingsPath });
+
+ expect(lstatSync(settingsPath).isSymbolicLink()).toBe(true);
+ const settings = read(tracked);
+ expect(settings.hooks).toBeUndefined();
+ expect(settings.model).toBe("opus");
+ expect(existsSync(hookPath)).toBe(false);
+ expect(readdirSync(dirname(tracked))).toEqual([TRACKED_SETTINGS]);
+ });
+
+ it("is a no-op on a settings file that was never installed into", () => {
+ const before: Settings = {
+ permissions: { allow: ["Read(**)"] },
+ hooks: { PostToolUse: [FOREIGN_POST_TOOL] },
+ };
+ seed(before);
+ expect(() => installCmd({ uninstall: true, settings: settingsPath })).not.toThrow();
+
+ expect(read()).toEqual(before);
+ });
+
+ it("is a no-op when the settings file does not exist", () => {
+ installCmd({ uninstall: true, settings: settingsPath });
+
+ // A removal that conjures a settings file (and a .claude directory) to
+ // prove it holds no hooks has made the user's machine messier, not
+ // cleaner.
+ expect(existsSync(settingsPath)).toBe(false);
+ expect(existsSync(dirname(settingsPath))).toBe(false);
+ expect(existsSync(hookPath)).toBe(false);
+ });
+ });
+});
+
+/** The repo root plus the two ends of `cx --version`: the manifest that owns
+ * the version, and the built entry point that has to report it. */
+const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url));
+const PACKAGE_JSON = new URL("../package.json", import.meta.url);
+const CLI_BUILT = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
+const TSC = fileURLToPath(new URL("../node_modules/typescript/bin/tsc", import.meta.url));
+
+/** tsc on this project takes a couple of seconds; leave the build room. */
+const BUILD_TIMEOUT_MS = 120_000;
+
+describe("cx --version", () => {
+ it(
+ "reports the version from package.json",
+ () => {
+ buildCli();
+ expect(existsSync(CLI_BUILT)).toBe(true);
+ const { version } = JSON.parse(readFileSync(PACKAGE_JSON, "utf8")) as { version: string };
+ const printed = execFileSync(process.execPath, [CLI_BUILT, "--version"], {
+ encoding: "utf8",
+ });
+
+ // A literal in cli.ts drifted from the published version once already;
+ // reading package.json at runtime is what keeps the two in lockstep.
+ expect(printed.trim()).toBe(version);
+ },
+ BUILD_TIMEOUT_MS,
+ );
+});
+
+/** `--version` is only observable from the built CLI, so compile it here
+ * rather than trust whatever is in dist/ - a build left over from other work
+ * can be newer than the source and still print the wrong thing. A failing
+ * compile is not this case's business (`tsc --noEmit` is the gate for that)
+ * and tsc emits anyway, so the assertions decide. */
+function buildCli(): void {
+ try {
+ execFileSync(process.execPath, [TSC], { cwd: REPO_ROOT, stdio: "inherit" });
+ } catch {
+ /* fall through: the version assertion reports whatever landed in dist/ */
+ }
+}
diff --git a/test/integration.test.ts b/test/integration.test.ts
index fc54725..e512ae0 100644
--- a/test/integration.test.ts
+++ b/test/integration.test.ts
@@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { connect } from "@infino-ai/infino";
import { indexRepo, indexRepoStaged } from "../src/core/indexer.js";
import { readManifest } from "../src/core/manifest.js";
-import { runSql, search } from "../src/core/searcher.js";
+import { runSql, vectorsNote } from "../src/core/searcher.js";
import type { IndexHandle } from "../src/core/context.js";
import type { Embedder } from "../src/core/embedder.js";
@@ -82,27 +82,40 @@ describe("indexing", () => {
});
});
-describe("search", () => {
- it("finds exact identifiers through the keyword half", async () => {
- const r = await search(handle, fakeEmbedder, "verifySession token", 5);
- expect(r.hits.length).toBeGreaterThan(0);
- expect(r.hits[0].path).toBe("src/auth.ts");
- expect(r.hits[0].startLine).toBeGreaterThan(0);
- expect(r.hits[0].content).toContain("verifySession");
+describe("ranked retrieval via sql TVFs", () => {
+ it("finds exact identifiers through bm25_search", async () => {
+ const rows = await runSql(
+ handle,
+ fakeEmbedder,
+ "SELECT path, start_line, content FROM bm25_search('chunks','content','verifySession token', 5)",
+ );
+ expect(rows.length).toBeGreaterThan(0);
+ expect(rows[0].path).toBe("src/auth.ts");
+ expect(Number(rows[0].start_line)).toBeGreaterThan(0);
+ expect(String(rows[0].content)).toContain("verifySession");
});
- it("hybrid ranking once vectors are ready", async () => {
- const r = await search(handle, fakeEmbedder, "session verification", 5);
- expect(r.ranking).toBe("hybrid");
- expect(r.hits.length).toBeGreaterThan(0);
- expect(r.hits[0].path).toMatch(/auth|README/);
+ it("ranks by meaning through hybrid_search once vectors are ready", async () => {
+ const rows = await runSql(
+ handle,
+ fakeEmbedder,
+ "SELECT path FROM hybrid_search('chunks','content','session verification','embedding', {{q}}, 5)",
+ { q: "session verification" },
+ );
+ expect(rows.length).toBeGreaterThan(0);
+ expect(String(rows[0].path)).toMatch(/auth|README/);
});
- it("keyword ranking while vectors are not ready", async () => {
- const noVec = { ...handle, manifest: { ...handle.manifest, vectors: "building" as const } };
- const r = await search(noVec, fakeEmbedder, "commit log", 5);
- expect(r.ranking).toBe("keyword");
- expect(r.note).toMatch(/vectors not ready/);
+ it("surfaces the vectors-not-ready note while bm25_search still answers", async () => {
+ const building = { ...handle, manifest: { ...handle.manifest, vectors: "building" as const } };
+ expect(vectorsNote(building.manifest)).toMatch(/vectors are still backfilling/);
+ expect(vectorsNote(handle.manifest)).toBeUndefined();
+ const rows = await runSql(
+ building,
+ fakeEmbedder,
+ "SELECT path FROM bm25_search('chunks','content','commit log', 5)",
+ );
+ expect(rows.length).toBeGreaterThan(0);
});
});
diff --git a/test/streaming.test.ts b/test/streaming.test.ts
index 641502b..731b47f 100644
--- a/test/streaming.test.ts
+++ b/test/streaming.test.ts
@@ -14,7 +14,7 @@ import { connect, type Connection } from "@infino-ai/infino";
import { APPEND_BATCH, EMBED_BATCH } from "../src/core/config.js";
import { indexRepo, indexRepoStaged, syncRepo, type SyncResult } from "../src/core/indexer.js";
import { readManifest } from "../src/core/manifest.js";
-import { search } from "../src/core/searcher.js";
+import { runSql } from "../src/core/searcher.js";
import { unpackRows, type Embedder } from "../src/core/embedder.js";
import type { IndexHandle } from "../src/core/context.js";
@@ -93,9 +93,13 @@ describe("streamed staged build", () => {
expect(Number(n)).toBe(stats.chunks);
const handle: IndexHandle = { root, dir, db, manifest: readManifest(dir)! };
- const r = await search(handle, float32Fake, "streamingfixture3 pipeline", 5);
- expect(r.ranking).toBe("hybrid");
- expect(r.hits.length).toBeGreaterThan(0);
+ const rows = await runSql(
+ handle,
+ float32Fake,
+ "SELECT path FROM hybrid_search('chunks','content','streamingfixture3 pipeline','embedding', {{q}}, 5)",
+ { q: "streamingfixture3 pipeline" },
+ );
+ expect(rows.length).toBeGreaterThan(0);
// Handoff files are gone once the vector stage settles.
expect(spillNames(dir)).toEqual([]);
@@ -127,9 +131,12 @@ describe("streamed staged build", () => {
// Keyword search still answers from the stage-1 table.
const handle: IndexHandle = { root, dir, db, manifest: readManifest(dir)! };
- const r = await search(handle, embedOnlyFake, "streamingfixture5", 3);
- expect(r.ranking).toBe("keyword");
- expect(r.hits.length).toBeGreaterThan(0);
+ const rows = await runSql(
+ handle,
+ embedOnlyFake,
+ "SELECT path FROM bm25_search('chunks','content','streamingfixture5', 3)",
+ );
+ expect(rows.length).toBeGreaterThan(0);
expect(spillNames(dir)).toEqual([]);
});
@@ -178,9 +185,13 @@ describe("streamed incremental sync", () => {
expect(outcome.chunks).toBe(stats.chunks + outcome.chunksAdded);
const handle: IndexHandle = { root, dir, db, manifest: readManifest(dir)! };
- const r = await search(handle, float32Fake, "syncwavefixture101", 3);
- expect(r.ranking).toBe("hybrid");
- expect(r.hits.some((h) => h.path === "src/mod101.js")).toBe(true);
+ const rows = await runSql(
+ handle,
+ float32Fake,
+ "SELECT path FROM hybrid_search('chunks','content','syncwavefixture101','embedding', {{q}}, 3)",
+ { q: "syncwavefixture101" },
+ );
+ expect(rows.some((r) => r.path === "src/mod101.js")).toBe(true);
});
});
@@ -244,8 +255,13 @@ describe("review-confirmed regressions", () => {
const [{ n: after }] = db.querySql(`SELECT COUNT(*) AS n FROM chunks`) as [{ n: unknown }];
expect(Number(after)).toBe(Number(before));
const handle: IndexHandle = { root, dir, db, manifest: readManifest(dir)! };
- const r = await search(handle, float32Fake, "streamingfixture0 pipeline", 3);
- expect(r.hits.some((h) => h.path === "src/mod0.js")).toBe(true);
+ const rows = await runSql(
+ handle,
+ float32Fake,
+ "SELECT path FROM hybrid_search('chunks','content','streamingfixture0 pipeline','embedding', {{q}}, 3)",
+ { q: "streamingfixture0 pipeline" },
+ );
+ expect(rows.some((r) => r.path === "src/mod0.js")).toBe(true);
expect(spillNames(dir)).toEqual([]);
// A later sync with a healthy embedder heals the same changeset.
diff --git a/test/sync.test.ts b/test/sync.test.ts
index 606b4b0..6895dbc 100644
--- a/test/sync.test.ts
+++ b/test/sync.test.ts
@@ -8,7 +8,7 @@ import { connect } from "@infino-ai/infino";
import { diffFiles, emptyFileState, hashContent, readFileState } from "../src/core/filestate.js";
import { indexRepo, syncRepo } from "../src/core/indexer.js";
import { readManifest } from "../src/core/manifest.js";
-import { search } from "../src/core/searcher.js";
+import { runSql } from "../src/core/searcher.js";
import type { IndexHandle } from "../src/core/context.js";
import type { Embedder } from "../src/core/embedder.js";
@@ -122,10 +122,23 @@ describe("syncRepo", () => {
expect(count("axolotl")).toBeGreaterThan(0);
expect(count("quokka")).toBe(0);
expect(count("wombat")).toBe(0);
- // hybrid search still works over synced rows (vectors were embedded)
- const s = await search(handle, fakeEmbedder, "axolotl", 3);
- expect(s.ranking).toBe("hybrid");
- expect(s.hits.some((h) => h.path === "src/gamma.ts")).toBe(true);
+ // hybrid retrieval still works over synced rows (vectors were embedded)
+ const rows = await runSql(
+ handle,
+ fakeEmbedder,
+ "SELECT path FROM hybrid_search('chunks','content','axolotl','embedding', {{q}}, 3)",
+ { q: "axolotl" },
+ );
+ expect(rows.some((r) => r.path === "src/gamma.ts")).toBe(true);
+ // Meaning-only ranking proves the synced rows were actually re-embedded:
+ // vector_search can only return rows that have vectors.
+ const vrows = await runSql(
+ handle,
+ fakeEmbedder,
+ "SELECT path FROM vector_search('chunks','embedding', {{q}}, 3)",
+ { q: "axolotl" },
+ );
+ expect(vrows.some((r) => r.path === "src/gamma.ts")).toBe(true);
});
it("is idempotent when a file is re-added identically (no duplicate rows)", async () => {
diff --git a/test/truncation.test.ts b/test/truncation.test.ts
index a033f1f..32e7226 100644
--- a/test/truncation.test.ts
+++ b/test/truncation.test.ts
@@ -11,8 +11,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { connect, type Connection } from "@infino-ai/infino";
import { indexRepo, syncRepo } from "../src/core/indexer.js";
import { readManifest, type Manifest } from "../src/core/manifest.js";
-import { search, partialIndex } from "../src/core/searcher.js";
-import type { IndexHandle } from "../src/core/context.js";
+import { partialIndex } from "../src/core/searcher.js";
import type { Embedder } from "../src/core/embedder.js";
const fakeEmbedder: Embedder = {
@@ -29,7 +28,6 @@ let db: Connection;
const cap = (maxFiles: number) => ({ maxFiles, maxFileBytes: 1024 * 1024 });
const writeFile = (n: number) =>
writeFileSync(join(root, "src", `f${n}.ts`), `export const value${n} = "token${n}";\n`);
-const handleFrom = (m: Manifest): IndexHandle => ({ root, dir, db, manifest: m });
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cx-trunc-"));
@@ -71,10 +69,10 @@ describe("truncation end to end", () => {
expect(m.truncatedFiles).toBe(2);
expect(m.maxFiles).toBe(1);
- const r = await search(handleFrom(m), fakeEmbedder, "token", 5);
- expect(r.partial).toBeDefined();
- expect(r.partial!.filesSkipped).toBe(2);
- expect(r.partial!.fileCap).toBe(1);
+ const p = partialIndex(m);
+ expect(p).toBeDefined();
+ expect(p!.filesSkipped).toBe(2);
+ expect(p!.fileCap).toBe(1);
});
it("omits truncation fields and the marker when the whole tree fits", async () => {
@@ -85,7 +83,7 @@ describe("truncation end to end", () => {
const m = readManifest(dir)!;
expect(m.truncatedFiles).toBeUndefined();
expect(m.maxFiles).toBeUndefined();
- expect((await search(handleFrom(m), fakeEmbedder, "token", 5)).partial).toBeUndefined();
+ expect(partialIndex(m)).toBeUndefined();
});
it("starts tracking truncation once a growing repo crosses the cap", async () => {
@@ -140,6 +138,6 @@ describe("truncation end to end", () => {
const m = readManifest(dir)!;
expect(m.truncatedFiles).toBe(1);
expect(m.maxFiles).toBe(1);
- expect((await search(handleFrom(m), fakeEmbedder, "token", 5)).partial!.filesSkipped).toBe(1);
+ expect(partialIndex(m)!.filesSkipped).toBe(1);
});
});
diff --git a/test/usage.test.ts b/test/usage.test.ts
index 2d50e87..601a0ac 100644
--- a/test/usage.test.ts
+++ b/test/usage.test.ts
@@ -5,7 +5,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from
import {
estTokens,
newSession,
- searchEntry,
sqlEntry,
formatReceipt,
recordUsage,
@@ -15,19 +14,6 @@ import {
recordHookEvent,
currentSessionStats,
} from "../src/core/usage.js";
-import type { SearchResult, SearchHit } from "../src/core/searcher.js";
-
-const hit = (path: string, content: string): SearchHit => ({
- path,
- startLine: 1,
- endLine: 10,
- lang: "ts",
- score: 1,
- content,
-});
-
-const result = (hits: SearchHit[]): SearchResult => ({ query: "q", ranking: "keyword", hits });
-
describe("estTokens", () => {
it("estimates ~chars/4", () => {
expect(estTokens("")).toBe(0);
@@ -36,46 +22,6 @@ describe("estTokens", () => {
});
});
-describe("search receipt", () => {
- let root: string;
- beforeAll(() => {
- root = mkdtempSync(join(tmpdir(), "cx-usage-"));
- writeFileSync(join(root, "a.ts"), "x".repeat(4000)); // ~1k tokens on disk
- writeFileSync(join(root, "b.ts"), "y".repeat(4000));
- });
- afterAll(() => rmSync(root, { recursive: true, force: true }));
-
- it("reports tokens returned and chunks/files, with no trailing markers", () => {
- const line = formatReceipt(searchEntry(result([hit("a.ts", "z".repeat(400)), hit("a.ts", "z".repeat(400))]), root));
- expect(line).toBe("returned ~200 tokens | 2 chunks / 1 file");
- });
-
- it("does not assert a whole-file counterfactual in the receipt", () => {
- // The estimate still lives on the entry (for the ledger), but the receipt
- // shown after every response makes no "vs reading them whole" claim.
- const entry = searchEntry(result([hit("a.ts", "z".repeat(40)), hit("b.ts", "z".repeat(40))]), root);
- expect(entry.wholeFileTokens).toBeGreaterThan(0);
- const line = formatReceipt(entry);
- expect(line).not.toMatch(/whole/);
- expect(line).not.toMatch(/\bvs\b/);
- });
-
- it("accumulates the session invocation count and token total across calls", () => {
- const session = newSession();
- formatReceipt(searchEntry(result([hit("a.ts", "z".repeat(400))]), root), session); // +100
- const line = formatReceipt(searchEntry(result([hit("a.ts", "z".repeat(400))]), root), session); // +100
- expect(session.queries).toBe(2);
- expect(session.returnedTokens).toBe(200);
- expect(line).toMatch(/invoked 2x this session \(~200 tokens total\)/);
- });
-
- it("counts the first call as invoked 1x", () => {
- const session = newSession();
- const line = formatReceipt(searchEntry(result([hit("a.ts", "z".repeat(40))]), "/nope"), session);
- expect(line).toMatch(/invoked 1x this session/);
- });
-});
-
describe("sql receipt", () => {
it("reports row count and token estimate of the rows", () => {
const line = formatReceipt(sqlEntry("SELECT 1", [{ path: "a.ts", lines: 12 }, { path: "b.ts", lines: 8 }]));
@@ -89,6 +35,14 @@ describe("sql receipt", () => {
expect(line).not.toMatch(/to read those files whole/);
expect(line).toMatch(/invoked 1x this session/);
});
+
+ it("accumulates the invocation count and token total across calls", () => {
+ const session = newSession();
+ formatReceipt(sqlEntry("SELECT 1", [{ z: "z".repeat(396) }]), session);
+ const line = formatReceipt(sqlEntry("SELECT 1", [{ z: "z".repeat(396) }]), session);
+ expect(session.queries).toBe(2);
+ expect(line).toMatch(/invoked 2x this session/);
+ });
});
describe("the ledger", () => {
@@ -97,17 +51,17 @@ describe("the ledger", () => {
afterAll(() => rmSync(dir, { recursive: true, force: true }));
it("round-trips entries oldest-first and captures the response summary", () => {
- recordUsage(dir, searchEntry({ query: "auth", ranking: "hybrid", hits: [hit("a.ts", "zzzz")] }, dir));
+ recordUsage(dir, sqlEntry("SELECT path FROM bm25_search('chunks','content','auth', 5)", [{ path: "a.ts" }]));
recordUsage(dir, sqlEntry("SELECT count(*)", [{ n: 3 }]));
const entries = readUsage(dir);
- expect(entries.map((e) => e.tool)).toEqual(["search", "sql"]);
- expect(entries[0].query).toBe("auth");
- expect(entries[0].hits?.[0]).toMatchObject({ path: "a.ts", startLine: 1, endLine: 10 });
+ expect(entries.map((e) => e.tool)).toEqual(["sql", "sql"]);
+ expect(entries[0].query).toContain("bm25_search");
+ expect(entries[0].rows).toBe(1);
expect(entries[1].rows).toBe(1);
});
it("skips torn / hand-edited lines instead of throwing", () => {
- writeFileSync(usageLogPath(dir), '{"tool":"search"}\nnot json\n', { flag: "a" });
+ writeFileSync(usageLogPath(dir), '{"tool":"sql"}\nnot json\n', { flag: "a" });
expect(() => readUsage(dir)).not.toThrow();
expect(readUsage(dir).length).toBeGreaterThanOrEqual(3);
});
@@ -125,7 +79,7 @@ describe("prompt telemetry (hooks)", () => {
afterEach(() => rmSync(dir, { recursive: true, force: true }));
const submit = (sid: string) => recordHookEvent(dir, { hook_event_name: "UserPromptSubmit", session_id: sid });
- const cxCall = (sid: string) => recordHookEvent(dir, { hook_event_name: "PostToolUse", session_id: sid, tool_name: "mcp__code-context__search" });
+ const cxCall = (sid: string) => recordHookEvent(dir, { hook_event_name: "PostToolUse", session_id: sid, tool_name: "mcp__code-context__sql" });
const otherCall = (sid: string) => recordHookEvent(dir, { hook_event_name: "PostToolUse", session_id: sid, tool_name: "Grep" });
it("counts prompts, cx calls, and prompts-that-used-cx (once per prompt)", () => {