@frsilva77/findex
v0.3.2
Published
Hybrid (BM25 + semantic) code search over local repos, backed by SQLite (FTS5 + sqlite-vec), exposed to Claude Code via MCP.
Readme
findex
Hybrid (BM25 + semantic) code search over local repos, backed by a local
SQLite file (FTS5 + sqlite-vec), exposed to Claude Code as an MCP tool.
No setup, service, or Docker container is needed: everything runs via npx,
the index lives at $HOME/.findex/db, and the embedding model is downloaded
to $HOME/.findex/models on first use.
Requirements
Node 22 or newer — but you do not have to make it your default.
If you run findex on an older Node, it looks for a Node 22+ you already have
installed (fnm, nvm, volta, asdf, n, Homebrew) and re-runs itself on that one,
printing which it picked. Nothing to switch, nothing to pass:
$ node --version
v20.20.2
$ npx -y @frsilva77/findex install-skill
findex requires Node 22 or newer — findex is on Node v20.20.2. Re-running it on Node v22.14.0 (found via fnm):
/Users/you/.local/share/fnm/node-versions/v22.14.0/installation/bin/nodeOnly when no Node 22+ can be found anywhere does it stop, with the upgrade command for the version manager you actually use.
The MCP server gets the same treatment, pinned once at install time rather than
re-resolved on every launch: install-skill registers it against the absolute path
of an installed Node 22+ whenever it finds one. That matters because Claude Code
spawns the server from its PATH — a GUI app never sources the shell rc file that
applies your fnm/nvm shim, so its node can be the old system one even when your
terminal shows v22. Use --node <path> to pin a specific binary yourself. With no
version-managed install to point at, it falls back to plain npx and the server's
own startup check.
See ADR 0016.
How to use
1. Index a repo
npx -y @frsilva77/findex index /absolute/path/to/some-repoRe-run the same command any time to pick up changes — unchanged files are skipped via content-hash comparison. Repeat for every repo you want Claude to be able to search; they all live in the same shared index.
2. Register the MCP server (and install the skill)
npx -y @frsilva77/findex install-skillThat registers the MCP server in your user scope — so it works in every project —
and installs the findex Claude Code skill described in step 3.
Re-running it is a no-op. npx -y @frsilva77/findex uninstall-skill reverses both,
leaving the index alone.
Useful flags: --dry-run (print what would happen), --no-mcp (skill only),
--force (overwrite an edited skill file), --index <path> (also index a repo,
repeatable), --server-name <name> (register under a different name),
--node <path> (pin the server to a specific Node 22+ binary).
If you'd rather edit your MCP config by hand, this is the equivalent entry. Prefer
the pinned form below unless you know the npx this resolves to is on Node 22+ in
Claude Code's environment: a hand-written config skips the install-time check, and
the server can only re-exec itself onto a newer Node if it finds one installed.
Note that findex-mcp is a bin inside the @frsilva77/findex package, not a
package of its own, so it needs --package:
{
"mcpServers": {
"findex": {
"command": "npx",
"args": ["-y", "--package=@frsilva77/findex", "findex-mcp"]
}
}
}Pinned form — an absolute Node 22+ plus npm's own npx-cli.js, so nothing is
resolved through PATH. This is what install-skill writes for you:
{
"mcpServers": {
"findex": {
"command": "/Users/you/.local/share/fnm/node-versions/v22.14.0/installation/bin/node",
"args": [
"/Users/you/.local/share/fnm/node-versions/v22.14.0/installation/lib/node_modules/npm/bin/npx-cli.js",
"-y", "--package=@frsilva77/findex", "findex-mcp"
]
}
}
}This exposes one tool, search_code, with params:
| param | type | default |
|---------|-----------------------------------|----------|
| query | string (required) | — |
| repo | string (optional) | all repos|
| mode | "hybrid" | "semantic" | "bm25" | "hybrid" |
| limit | number | 5 |
| detail | "compact" | "full" | "compact" |
Results come back as plain text, one block per hit — repo · file:startLine-endLine
· symbolName followed by the code. "compact" previews the first 8 lines of each
hit and tells Claude where to read the rest; "full" returns the whole matched
chunk. Overlapping chunks of the same file are fused into one result, and the
response is capped so a single search can't flood the conversation
(see ADR 0011).
npm run bench:tokens measures what a response costs, against whatever is already
indexed — useful when tuning limit, detail, or the chunk size cap.
3. Ask Claude
Step 2 also installs a Claude Code skill at ~/.claude/skills/findex/. Without it,
agents tend to fall back on Grep/Glob loops out of habit; the skill tells Claude
to reach for search_code first, to read the exact line ranges it returns instead of
whole files, and when grep genuinely is the better tool. It triggers on its own, or
you can invoke it explicitly with /findex.
Once one or more repos are indexed, you can ask Claude conceptual questions about the code:
Where do we validate webhook signatures?
Claude calls search_code({ query: "webhook signature validation" }), gets
back ranked chunks, and can answer by reading the specific files/line ranges
returned instead of grepping the whole codebase.
Where do we retry failed background jobs?
How does
my-servicecalculate rate limits? (scope to one repo:search_code({ query: "rate limit calculation", repo: "my-service" }))
You can also call search_code directly via the MCP tool in Claude Code
without phrasing it as a question — pass the query/repo/mode/limit params
explicitly if you want a specific search strategy (e.g. mode: "bm25" to
force an exact-term lexical match).
Optional: per-repo config
Copy findex.config.example.json to
findex.config.json inside the repo you're indexing, to extend/force-include
ignore patterns or file extensions, or tune chunk size.
How it works
- Indexing (CLI, manual, one repo at a time): walks a local folder,
respects
.gitignore+ a default ignore list, chunks TS/JS files by AST symbol (function/class/method) via tree-sitter and everything else by fixed-size sliding window, embeds each chunk locally with@huggingface/transformers, and stores it in a single shared SQLite database (all indexed repos live in one file, distinguished by arepocolumn). Re-running the index command only re-embeds files whose content hash changed. - Search (MCP tool, called by Claude): runs BM25 (SQLite
FTS5) and vector similarity (sqlite-vec) search in parallel and fuses the two ranked lists with Reciprocal Rank Fusion. Returns{file, startLine, endLine, snippet, score}per match.
The MCP server never triggers indexing itself — if a repo isn't indexed yet or looks stale, it tells you which CLI command to run.
See docs/adr/ for the reasoning behind every major design decision.
Development
Working on findex itself (not just using it):
npm install
cp .env.example .env
npm run typecheck
npm testThis repo uses npm — there is no yarn lockfile, and engine-strict in
.npmrc enforces the Node version in engines.
.env optionally overrides the embedding model name or the SQLite index
location — see .env.example. Run against source directly
with npm run index index <path> / npm run mcp, without building first.
Releasing
Releases are automatic: every pull request merged into main publishes a new
version of @frsilva77/findex to npm, tags it, and creates a GitHub release
(.github/workflows/release.yml). There is no
manual publish step.
How big the bump is comes from the labels on the pull request:
| Label | 0.2.1 becomes | Use for |
| --------------- | --------------- | ------------------------------------------ |
| (none) | 0.2.2 | the default — fixes, deps, small changes |
| release:patch | 0.2.2 | same as the default, stated explicitly |
| release:minor | 0.3.0 | new features, new CLI flags or MCP tools |
| release:major | 1.0.0 | breaking changes for existing users |
| release:skip | nothing | docs- or CI-only changes worth no release |
The highest label wins if more than one is applied. A commit pushed straight to
main belongs to no PR and releases a patch.
The workflow runs npm run typecheck and npm test before publishing, and commits
the bumped package.json back to main as chore: release vX.Y.Z [skip ci], so
package.json always reflects the latest published version.
Two things still need a human:
- If
src/core/schema.sqlchanged shape, bumpSCHEMA_VERSIONinsrc/core/db.tsin the same PR. It is versioned independently of the package on purpose (see ADR 0013) — most releases should not touch it. - Choosing the label. Note that since the MCP config and docs float
@latest(no pinned version), a merge is live for every user within minutes.
See ADR 0017 for why labels rather than Conventional Commits.
