Code Indexing MCP Server
  • Rust 97.3%
  • Shell 1.3%
  • Python 0.7%
  • WebAssembly 0.3%
  • PowerShell 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
buildagent 59b8bc3d2d
All checks were successful
CI / cargo doc (intra-doc links) (push) Has been skipped
CI / Grammar rebuild from source (nightly) (push) Has been skipped
CI / OSS corpus tier-3 scale (nightly) (push) Has been skipped
CI / cargo test (push) Has been skipped
Package CI / package-corpus (svelte) (push) Successful in 2s
CI / cargo test (daemon transport) (push) Has been skipped
CI / Plugin path cost + pool throughput (nightly) (push) Has been skipped
Package CI / package-corpus (ruby) (push) Successful in 2s
CI / Report scheduled failures (push) Has been skipped
Package CI / package-corpus (python) (push) Successful in 3s
Package CI / package-corpus (rust) (push) Successful in 3s
Package CI / package-corpus (xaml) (push) Successful in 1s
Package CI / package-corpus (vue) (push) Successful in 3s
Package CI / package overhead budgets (nightly, one package at a time) (push) Has been skipped
Package CI / package-corpus (typescript) (push) Successful in 3s
Package CI / package-corpus (timeline) (push) Successful in 3s
Package CI / Report scheduled failures (push) Has been skipped
Pilot plugin compatibility / Fetch the pinned runtime once (push) Successful in 17s
Pilot plugin compatibility / Pilot compatibility (csharp) (push) Successful in 9s
Pilot plugin compatibility / Pilot compatibility (timeline) (push) Successful in 11s
Pilot plugin compatibility / Pilot compatibility result (push) Successful in 1s
Release Build / Generate Version (push) Successful in 7s
Release Build / Required CI green (push) Successful in 29s
Release Build / Build linux-aarch64 (push) Successful in 11m42s
Release Build / Build windows-x86_64 (push) Successful in 26m33s
Release Build / Build linux-x86_64-musl (push) Successful in 16m36s
Release Build / Build linux-x86_64 (push) Successful in 17m39s
Release Build / Assemble the release archives (push) Successful in 41s
Release Build / Windows archive smoke (msvc) (push) Successful in 54s
Release Build / Seed the package channel (push) Successful in 12s
Release Build / Create Forgejo Release (push) Successful in 26s
Pilot plugin compatibility / Pilot scope (push) Successful in 12s
CI (Windows) / changes (what this run must test) (push) Successful in 17s
CI (Windows) / fmt + clippy + build + test (windows) (push) Has been skipped
CI / changes (what this run must test) (push) Successful in 18s
Package CI / package suites (clippy) (push) Has been skipped
Package CI / changes (which packages this run must test) (push) Successful in 18s
Package CI / package (csharp) (push) Successful in 1s
Package CI / package (php) (push) Successful in 2s
Package CI / package (svelte) (push) Successful in 1s
Package CI / package (rust) (push) Successful in 1s
Package CI / package (javascript) (push) Successful in 3s
Package CI / package (ruby) (push) Successful in 4s
Package CI / package (python) (push) Successful in 3s
Package CI / package (timeline) (push) Successful in 2s
Package CI / package (vue) (push) Successful in 2s
Package CI / package (typescript) (push) Successful in 2s
Package CI / package (xaml) (push) Successful in 3s
CI / cargo fmt (push) Successful in 26s
CI / cargo clippy (push) Has been skipped
Package CI / package-corpus (php) (push) Successful in 2s
CI / cargo check (MSRV 1.99.1) (push) Has been skipped
CI / cargo check (windows-gnu) (push) Has been skipped
CI / cargo test (abi, 32-bit + wasm32) (push) Has been skipped
CI / cargo deny (push) Has been skipped
CI / OSS corpus (tier 1) (push) Has been skipped
Package CI / package-corpus (javascript) (push) Successful in 3s
Package CI / package-corpus (csharp) (push) Successful in 4s
CI / CI lane wall-clock headroom (push) Has been skipped
CI / guest crates (fmt, clippy, doc) (push) Has been skipped
Merge PR #587: v0.41.53, restart and update behaviour a session can rely on
Fixes #584, #585 and #586. Qualification and the four CI rounds are in the pull request description.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XLNPso9cCvzGq5Kt72PSr
2026-11-14 11:54:07 +01:00
.cargo State directory and index DB: trust them only on positive evidence 2026-10-15 22:52:28 +02:00
.config test: run the text length refusal bound in the timing profile 2026-11-12 18:34:54 +01:00
.forgejo ci: install cargo-deny outside the download-retry helper 2026-11-14 07:06:24 +01:00
_prdoc docs: keep the I068 design records as they were written 2026-11-13 17:50:59 +01:00
crates test: print the daemon's state when a ready daemon answers no callers 2026-11-14 05:57:36 +01:00
distribution release: v0.41.53 2026-11-14 00:36:24 +01:00
docs Merge lane r44: recovery waits through a starting or dying lock holder and reports restarts as retryable (#584) 2026-11-13 20:12:41 +01:00
fuzz build: upgrade to Rust 1.99.1 and edition 2024 2026-10-26 14:37:52 +01:00
tests ci: install cargo-deny outside the download-retry helper 2026-11-14 07:06:24 +01:00
vendor fix: harden automatic plugin maintenance for v0.41.25 2026-10-31 11:28:50 +01:00
.gitattributes Fix Windows SQLite shared-lock races and native overflow recovery 2026-10-26 23:06:31 +01:00
.gitignore State directory and index DB: trust them only on positive evidence 2026-10-15 22:52:28 +02:00
.mcp.json.example chore: gitignore .mcp.json, keep .mcp.json.example 2026-05-13 07:19:35 +02:00
AGENTS.md Restore handwritten instructions and qualify benchmark precision 2026-11-11 10:21:59 +01:00
ARCHITECTURE.md fix(mcp): preserve semantic argument refusal codes 2026-11-11 22:16:04 +01:00
Cargo.lock release: v0.41.53 2026-11-14 00:36:24 +01:00
Cargo.toml release: v0.41.53 2026-11-14 00:36:24 +01:00
CLAUDE.md Restore handwritten instructions and qualify benchmark precision 2026-11-11 10:21:59 +01:00
CONTRIBUTING.md docs: correct contributor build and language package guidance 2026-11-12 12:08:08 +01:00
deny.toml plugin-host: precompiled grammars through a verified wasmtime module cache 2026-09-26 21:41:42 +02:00
install.ps1 fix: report external PATH installs during installer checks (#572) 2026-11-11 18:55:36 +01:00
install.sh fix: report external PATH installs during installer checks (#572) 2026-11-11 18:55:36 +01:00
README.md release: v0.41.53 2026-11-14 00:36:24 +01:00
rust-toolchain.toml build: upgrade to Rust 1.99.1 and edition 2024 2026-10-26 14:37:52 +01:00

code-index

Fast structural code index for AI coding agents, exposed over MCP. See the MCP argument reference for shared options and errors. See the configuration reference for .code-index.toml and environment variables.

Language packages cover Rust, Python, TypeScript / JavaScript, C#, PHP, Ruby and additional languages. Live updates via filesystem watcher. Symbols, references, imports, full-text search. Local SQLite, no cloud, no telemetry, no account. The daemon is a local process on a token-authenticated loopback socket, not a network service.

Quick start

Install, and update, with one command

curl --proto '=https' --tlsv1.2 -sSfL https://git.h-dv.de/h-dv/code-index/raw/branch/master/install.sh | sh

The same command installs and updates: it asks the release API for the latest tag, and if that version is already installed it says so and exits without downloading anything. There is no separate self-update subcommand, deliberately — a self-update cannot install the first copy, so it can only ever be half a mechanism, and the half that rots.

sh install.sh --check                  # installed vs published; writes nothing
sh install.sh --tag v0.27.1            # a specific release, including an older one
sh install.sh --prefix "$HOME/.local"  # default: /usr/local if writable, else ~/.local
sh install.sh --with-plugin de.h-dv.xaml   # also put this package in the user's
                                           #   store; it GRANTS NOTHING

--check (PowerShell: -Check) also reports the path and version of a code-index found on PATH outside the selected prefix.

--with-plugin <id> installs a package and grants it nothing. It resolves the id through the distribution catalog and writes the bytes into the user-scoped package store — which runs no code: an installed package extracts nothing until a project approves it, and approving is the separate step that names the capabilities. That step is code-index plugin add <id>, run by you, from inside the project, and the installer prints the line rather than running it. It used to run plugin add --grant requested --yes, which made the FIRST capability grant on a fresh machine non-interactively, inside a curl | sh, for whichever project the working directory happened to detect. The first grant through this installer path stays a human decision. If the package install fails the script stops and names the state you are in: the four binaries ARE installed and answering, the failure is about the plugin only, and nothing was granted to any project.

It refuses rather than guesses, and each refusal is a hazard that has a name:

  • It never installs bytes it has not verified. The .sha256 sidecar published beside every archive is downloaded and compared. If neither sha256sum nor shasum is on PATH it refuses instead of skipping the check — a verification that silently does not run is worse than none, because it reads as having run.
  • A partial archive installs nothing, and neither does a partial write. The four binaries are staged in a directory inside the target, verified there, and only then renamed into place — so a failure part way through cannot leave two new binaries beside two old ones. That mixture is a state nothing tests, and the build-skew disclosure would then be describing a set rather than a build.
  • A release that cannot run here is refused before anything is replaced. The staged code-index is asked its own version first, so a wrong-libc or mis-rolled archive leaves your existing install untouched instead of bricking it with no way back.
  • An archive member that is a symlink is refused. A symlink resolves against your machine, so it would install local bytes under one of our names — and the default prefix is /usr/local.
  • macOS is refused by name, citing #59, because no macOS archive is published and a Linux binary placed on a Mac fails later and less clearly. An unrecognised uname prints what it saw.
  • Windows is handed over, not half-served. Windows ships a .zip, not a .tar.gz; this script says so and points at install.ps1, which does the same job natively — see Windows.
  • It never signals a daemon. If it finds a daemon lockfile it says an older build may still be answering and names code-index doctor.

crates/cli/tests/installer_e2e.rs grades every one of those refusals against a real archive, and asserts in each case that nothing was installed — not merely that the exit code was non-zero. Which platforms the script must handle is not a list anyone maintains: crates/cli/tests/installer_targets.rs derives the published set from the release workflow's own matrices and fails if an archive is neither installable nor waived by name.

Anything else you would rather do by hand: grab an archive from git.h-dv.de/h-dv/code-index/releases (linux-x86_64, linux-x86_64-musl, linux-aarch64, windows-x86_64; bundles the four binaries plus a per-platform README and .code-index.toml.example) and verify it against the .sha256 beside it.

Windows: one command, and the same command updates

install.ps1 is a first-class installer and updater for Windows, and like install.sh it is one script for both directions — the install path and the update path are the same code, so an update cannot rot while installs keep working.

irm https://git.h-dv.de/h-dv/code-index/raw/branch/master/install.ps1 | iex

That form takes no arguments. iex runs the downloaded text, and there is nowhere in that pipeline to put a -Tag — anything after iex is an argument to iex, not to the script. So for anything with flags, download it and run it:

irm https://git.h-dv.de/h-dv/code-index/raw/branch/master/install.ps1 -OutFile install.ps1
powershell -ExecutionPolicy Bypass -File .\install.ps1 -Check
.\install.ps1 -Check                     # installed vs published; writes NOTHING
.\install.ps1 -Tag v0.27.1               # a specific release, including an older one
.\install.ps1 -Prefix D:\tools\ci        # default: $env:LOCALAPPDATA\Programs\code-index
.\install.ps1 -BaseUrl D:\offline\assets # a directory works, which is what makes an
                                         #   offline install possible
.\install.ps1 -WithPlugin de.h-dv.xaml   # also put this package in the user's
                                         #   store; it GRANTS NOTHING
.\install.ps1 -AddToPath                 # opt in to a USER PATH edit
.\install.ps1 -Help                      # the full list

The four .exe files go directly in the prefix — %LOCALAPPDATA%\Programs\code-index\code-index.exe, not ...\bin\code-index.exe. That is where this deliberately differs from install.sh: a bin\ level is a POSIX shape, and Windows software does not install that way.

-AddToPath is opt-in, and the default is to print the line rather than run it. The default prefix is not on anybody's PATH, so the installer knows perfectly well that you will want it there — and editing your PATH is still a mutation you did not ask for. Without the flag your environment is not touched; with it the edit is to the user environment only, never the machine one.

-WithPlugin <id> installs a package and grants it nothing, exactly as on POSIX: the bytes go into the user-scoped package store, which runs no code, and code-index plugin add <id> — the step that names the capabilities — is printed for you to run from inside the project. The first grant through this installer path stays a human decision. If the package install fails, the script stops and names the state you are in: the four binaries ARE installed and answering, and nothing was granted to any project.

It refuses rather than guesses, and each refusal is a hazard that has a name:

  • It never installs bytes it has not verified. The .sha256 sidecar published beside the archive is downloaded and compared. If neither Get-FileHash nor System.Security.Cryptography.SHA256 can be reached it refuses instead of skipping the check — a verification that silently does not run is worse than none, because it reads as having run.
  • A hostile archive member refuses the whole archive, before a byte is written. Expand-Archive does not protect you from one. So every entry is inspected first: an absolute name, a .. segment, a backslash, a name that escapes the destination once normalised, a symlink, a reparse point, or anything that is not a plain file or directory, and the install stops with nothing unpacked.
  • A partial set installs nothing. All four binaries are staged inside the install directory, verified there, and only then moved into place. A failure part way through rolls back, so a truncated archive cannot leave two new binaries beside two old ones.
  • A release that cannot run here is refused before anything is replaced. The staged code-index.exe is asked its own --version first, so a mis-rolled or wrong-architecture archive leaves your existing install whole.
  • It never signals a daemon. If it finds one it says so and names the command; it does not kill a process it did not start.

The running-daemon case, which is a Windows problem and not a POSIX one. Windows will not open a running image for writing, so an update while the daemon is alive would fail with access denied half way through. A running .exe can be renamed, though — so each target is renamed aside to <name>.old-<stamp> and the new file is moved into the name it vacated, and if any of the four fails the whole set is rolled back. Deleting an .old- file then fails for as long as the old process lives, which is fine and expected: those files are swept by the next run. You may see them beside the binaries in the meantime.

Supported on Windows also means what it meant before: windows-x86_64 is built for every release, the whole suite is fmt/clippy/build/tested natively on a Windows runner, and since 2026-08-11 that job gates releases — a red Windows leg blocks publishing rather than being noted afterwards. The cfg(windows) code paths (payload-DACL hardening, the child-process e2e tests) run there and nowhere else.

install.sh is still a POSIX shell script and still refuses by name under MinGW, MSYS, Cygwin or with Windows_NT in the environment, rather than half-unpacking a .tar.gz that was never published for you — and that refusal now prints the install.ps1 command instead of a manual download. install.ps1 refuses the mirror case — pwsh on Linux or macOS — by name, and points back at install.sh.

There is still no winget package and no MSI.

The archive by hand is the fallback, not the route. If you would rather not run a script at all:

$Tag = "v0.41.53"
$Base = "https://git.h-dv.de/h-dv/code-index/releases/download/$Tag"
Invoke-WebRequest "$Base/code-index-$Tag-windows-x86_64.zip"        -OutFile ci.zip
Invoke-WebRequest "$Base/code-index-$Tag-windows-x86_64.zip.sha256" -OutFile ci.zip.sha256

# VERIFY BEFORE UNPACKING. The sidecar is `<hash>  <filename>`; compare the hash.
(Get-FileHash ci.zip -Algorithm SHA256).Hash.ToLower()
Get-Content ci.zip.sha256

Expand-Archive ci.zip -DestinationPath "$env:LOCALAPPDATA\Programs"
# then put the extracted folder on PATH

The archive contains the four .exe binaries, a per-platform README.md, VERSION.txt and .code-index.toml.example. Keep all four binaries together — see Binaries. Note that Expand-Archive there is doing the unpacking unguarded, which is the check install.ps1 adds and the reason to prefer it.

For automation, new releases attach install.sh, install.ps1 and a .sha256 for each to the release itself. Pin both the script URL and its tag argument; pinning the URL alone still asks the script to install the latest binaries:

TAG=v0.41.53
curl -sSfLO "https://git.h-dv.de/h-dv/code-index/releases/download/$TAG/install.sh"
curl -sSfLO "https://git.h-dv.de/h-dv/code-index/releases/download/$TAG/install.sh.sha256"
sha256sum -c install.sh.sha256 && sh install.sh --tag "$TAG"
$TAG = "v0.41.53"
$Base = "https://git.h-dv.de/h-dv/code-index/releases/download/$TAG"
Invoke-WebRequest "$Base/install.ps1"        -OutFile install.ps1
Invoke-WebRequest "$Base/install.ps1.sha256" -OutFile install.ps1.sha256

# the sidecar is `<hash>  install.ps1`; compare before running the script
(Get-FileHash install.ps1 -Algorithm SHA256).Hash.ToLower()
(Get-Content install.ps1.sha256).Split()[0]

powershell -ExecutionPolicy Bypass -File .\install.ps1 -Tag $TAG

Release-pinned install.sh assets are available starting with v0.28.1; install.ps1 is attached from the first release published after it was added. Older releases use the source URL above — and for a release older than that, the archive-by-hand recipe above is the only path.

Or build from source

# build
cargo build --release

# initialise a project (one-off)
./target/release/code-index init
./target/release/code-index index

# health check
./target/release/code-index doctor

The source build does not assemble the signed package bundle carried by release archives. Automatic first-party maintenance can fetch compatible packages when the project is indexed or opened; see automatic maintenance for its policy and opt-outs. With maintenance disabled, install and approve the needed packages explicitly. The default Cargo features include only Ruby's native fallback; --features builtins enables the native test oracles.

macOS: no prebuilt archive, and no macOS job in CI. macOS release builds are deliberately deferred (#59); no date is promised. The source carries macOS-only code paths — the FSEvents watcher subtree subscription, the daemon's stale-PID detection and its flock handling — and nothing in CI compiles or runs them. A check-only gate is not a cheap substitute: cargo check --target x86_64-apple-darwin still runs build scripts, and the tree-sitter grammars compile C through cc, which fails on this image with cc: error: unrecognized command-line option '-arch'. So building from source on macOS is not something we have verified; treat it as unsupported rather than untested-but-probably-working.

Wire into Claude Code (or any MCP client):

// claude_desktop_config.json or equivalent
{
  "mcpServers": {
    "code-index": {
      "command": "/path/to/code-index-mcp",
      "args": ["--root", "/path/to/your/project"]
    }
  }
}

That's it. The MCP server auto-spawns the daemon on first connect and initialize returns in milliseconds (see I015) regardless of project size; the daemon runs its initial reconciliation in the background and lives independently of your editor session until 30 minutes of idle (configurable), shutdown or a schema change. The MCP tools, resources, and prompts are available immediately — calls in the cold-start window either succeed, return state: "reconciling", or return a structured index_incomplete error; follow its retryable flag and remedy.

If startup reconciliation fails, the RPC daemon stays alive and serves the last durable snapshot instead of entering an index/fail/respawn loop. project_overview reports state: "degraded" with state_detail, and the daemon retries in place with an exponential cooldown that is never shorter than the failed attempt itself. The state clears after recovery.

Upgrading to 0.32

  • Run code-index index once in each project after upgrading. This release changes what the extractor records, so the first reconcile reparses and re-resolves every code file: about a minute on a django-sized repository, roughly the cost of a cold index. An MCP session's daemon does this on its own and answers with state: "reconciling" meanwhile. A one-shot code-index query with no daemon running (a hook, a pre-commit check, CI) waits up to 10 s for it; if that is not enough it starts the code-index daemon to finish it and answers index_incomplete with a reconcile_pending block. Read retryable and reconcile_pending.converges_on_retry: a durable resolution queue or serving daemon can make retries converge, including with --no-daemon, which never starts a daemon. Otherwise follow the reported remedy. One code-index index settles it up front instead. If a daemon is already serving the project (an MCP session is open), code-index index does not index a second time: it names the daemon's pid, says how to follow its progress (code-index query project_overview '{}', or code-index doctor), and exits 0. The daemon is already doing the reconcile.
  • pytest fixture parameters bind the fixture they request. A parameter of a test_* function, of a test_* method of a Test* class, or of a fixture binds the @pytest.fixture of that name, found the way pytest finds it: the test's class, then its module, then the nearest conftest.py upward. name= aliases are honoured and a fixture never resolves to itself. Nothing is bound for two fixtures of one name at the deciding level, for a name @pytest.mark.parametrize supplies, or for a call of the parameter (that calls the fixture's value).

Upgrading to 0.13

  • Gitignored files are no longer indexed by the live watcher. It now applies the same ignore-FILE stack the cold walk does, so the two agree. If you were relying on a gitignored path being searchable, it was only ever searchable until the next re-walk deleted it.
  • On Windows, index.db and its sidecars are now owner-only. They previously inherited the parent directory's ACLs, while daemon.toml — which holds only the token guarding access to that index — did not. See the note below on what the index actually holds.
  • WatchOpts gains ignore_change_rewalk; WatcherStats was removed in 0.12.

Upgrading to 0.12

Four changes you will notice:

  • A nested git checkout is no longer indexed as part of the project. A subdirectory carrying its own .git or .jj — a submodule, a linked worktree, a nested clone — belongs to another project at another commit, and indexing it here duplicated every symbol into these tables. If you relied on that, index it as its own project with code-index link add <name> <path>, which also gives it correct resolution. The walker logs each one it prunes.
  • Excluded hidden paths are purged from existing indexes at the next reconcile. The live watcher used to index what the cold walk excluded, so .env, .claude/, .vscode/ and .git internals could end up in the index — contents included, retrievable through search_text. Those rows go away on upgrade. Nothing you index today is lost: the cold walk never yielded them. CI directories .github, .gitlab and .forgejo remain eligible; hidden children inside them are still excluded.
  • project_overview can report state: "resyncing". A full re-walk is in progress. Results stay readable and every tool still answers; freshness is unverified until it clears, which it does on its own.
  • WatcherStats is removed from code-index-indexer's public API. Nothing ever populated it.

A branch switch now heals in seconds instead of waiting out the periodic reconcile, and on Windows a large event burst — where the OS gives no overflow signal at all — triggers recovery once the filesystem goes quiet.

The live watcher now applies the ignore-FILE stack too (.gitignore, .ignore, .git/info/exclude, .code-index-ignore, and your global core.excludesFile), so it agrees with a cold index rather than indexing gitignored files and deleting them again on the next re-walk. Editing a .gitignore while the daemon runs is honoured immediately.

The index holds what your working tree holds

.code-index/index.db is a structural copy of every file the walker indexes, plus their text. Treat it with the same care as the source tree itself — it is not a filtered or sanitised view, and no exclusion rule makes it one. What the exclusion rules give you is AGREEMENT between a cold index and a live one, not a security boundary.

So the index is protected at the level source code is: the state directory is created 0700, index.db is pre-created 0600 before SQLite opens it (a chmod afterwards would leave a window), the WAL and shm sidecars inherit that, and on Windows all three get a protected owner-only DACL — the same hardening daemon.toml gets, because protecting the capability token but not the index it guards is not a threat model. .code-index/ is added to .git/info/exclude so the index never follows your source into a commit.

Project-scoped .mcp.json: gotcha

If you drop a per-project .mcp.json (instead of the user-scope claude_desktop_config.json shown above), Claude Code will not register the server retroactively in an already-running session. Symptom: code-index tools don't appear in the agent's tool list, and the agent silently does without them. Two paths out:

  1. Restart Claude Code in the project dir and approve the prompt. When a project-scoped .mcp.json is detected for the first time, Claude Code asks the user to approve each declared server. Decline once and it stays declined.
  2. Or pin code-index at user scope so it's available everywhere without per-project approval:
    claude mcp add code-index code-index-mcp
    claude mcp list   # verify
    

code-index doctor includes an mcp registration check that surfaces the most common cause ("project hasn't been opened in Claude Code yet") with a pointer to claude mcp list.

What the agent gets

  • Handles, not blobs. Symbol responses carry IDs, locations, and signatures — never embedded source. The agent calls read_code only when it actually needs bytes.
  • Paginated, token-bounded responses. Lists return {results, total, next_cursor}; read_code is hard-capped at 4000 tokens with a truncated: true flag on overflow.
  • Structured errors. {error, query?, did_you_mean[], hint?} — not free-text strings.
  • Live freshness. Edits, creates, renames, and deletes in your editor flow into the DB within a few hundred milliseconds. Atomic saves are coalesced, and references re-resolve per change batch — cheaply, thanks to an outline-hash firewall that skips the global pass when only function bodies changed.
  • Two denominators, never one. Symbol rows carry ref_count (references that RESOLVED) and name_fallback_count (same-name references that resolved to nothing). The second is an upper bound on how much the first undercounts — not more references: a common method name is dominated by unrelated same-name calls, nearly all of them stdlib. Both numbers ship in the row, so ask your own repository rather than reading a figure from here; project_overview's count_basis measures which zeros are uninformative on it. 0 is the informative case: nothing unresolved could be this symbol, so ref_count is tight for the shapes that counter can see (it cannot see alias-qualified calls, Rust/PHP fields, or Rust consts — so 0 is never proof of "unused"). A blast radius you can trust is worth more than a number that flatters.
  • Coverage you can ask about. index_coverage(path) answers "is this indexed, and if not, why not?" with indexed / pending / never — and never only ever with proof, naming the rule that fired (hidden, skip_dir, ignore_file, too_large, …). No static documentation can answer this, because half the rule is your own .gitignore: deploy/dev.env is indexed in one repo and excluded in the next.
  • An inventory, not just a search. list_files(path_glob) answers "what is HERE" — path, lang, line and byte counts measured from disk, symbol count from the index, and per row whether the file is parsed for symbols or reachable only as text. Beside it, not_indexed names the files in that directory the index cannot see at all, with the rule that hid each one. read_code takes a bare path too, so reading a file you have not outlined yet is one call and a truncated read reports total_lines rather than a silent prefix.
  • Precise-enough references. Resolution is three-tiered: symbol visibility (recorded by the language extractors) gates candidates, cross-file bindings additionally require reachability (same dir, or an import naming the candidate's file, identifier, or container type — so a lone exported get no longer swallows every stdlib .get() call), same-file definitions win over same-name twins elsewhere, and the ref's own imports break remaining ties; unqualified heuristic shortcuts never apply to qualified calls, so Vec::new()-style externals stay honestly unresolved. The rate is a property of your repository, not a headline number: project_overview reports refs and refs_resolved as counts, resolution as the per-language percentage, and resolution_by_kind as the split that says which shapes the rate is coming from.
  • response_format: "concise" on tools that support it cuts rows to handles + signatures (roughly half the tokens) when scanning many results. find_references and find_callers take exclude_tests: true to hide test-file references; excluded_test_refs then reports the hidden count split into resolved vs name_fallback, because the filtered mode is the one where you can no longer read resolution per row. Both tools carry total_resolved beside total, and every confidence block carries page_rows so a page_resolved: 0 can be told apart from an empty page.
  • Fewer calls, fewer re-reads. read_code takes a LIST of targets (up to 8, one batches entry each, sharing an 8,000-token content budget that names any entry it could not read), as search_text and search_symbols take a list of queries. search_text's max_lines_per_file (default 20, hard cap 1,000) lists every matching line of a file for a rename check; lines_widening states the cap that applied and names any file it could not widen. find_callers on a type answers target_is_type — call edges attach to its members — with the members that do have callers.
  • envelope: "minimal", on any tool, opt-in. Drops the semantics prose and the per-call answer_provenance / index_snapshot blocks and keeps every disclosure FIELD — counts, *_unmeasured markers, verdicts, refusals, freshness state/incomplete, and a skewed daemon_build, hoisted. The reply says what it omitted in one envelope field. The default reply is unchanged. Measured on a 14-call mix over this repository it saved 18.3% of the characters (index_coverage 68%, project_overview 23%, a long file_outline under 3%); code-index://docs/envelope has the table.

Agent skill

The agent skill is served over MCP as skill://code-index/SKILL.md, with skills/list and skills/get for discovery and retrieval. The published manifest's SHA-256 and byte size let clients verify the document. Clients which do not read MCP resources can save that resource's text themselves.

Orientation and diff awareness

Two tools answer the questions agents ask at the start and end of a task:

// "Where do I even look?" — token-budgeted architectural map, ranked
// by personalized PageRank over the reference graph. Pass `focus`
// to pull a task-relevant neighborhood to the top.
repo_map { "token_budget": 1500, "focus": ["resolve_ref_targets"] }

// "What did I just touch, and what depends on it?" — symbols
// intersecting your git diff, with per-symbol direct_callers.
// Replaces the git-diff + N-greps ritual before a commit.
changed_symbols { "base": "HEAD", "staged": false }

In watcher-backed sessions, changed_symbols and review_diff wait briefly for changed source files whose index row is behind disk. If reconciliation does not finish inside the bounded wait they return index_updating; they never return shifted symbol ids/spans as usable output. Snapshot mode (--no-daemon) cannot heal and instead preserves the explicit per-file index_stale: true diagnostic. A changed file the index does not cover at all — a skip directory (dist/, target/, node_modules/, …), any per-directory ignore rule the walker honours (.code-index-ignore, a nested .gitignore, .git/info/exclude), a hidden dot-file, one over the file-size cap, or one the OS will not let the indexer read at all (a permissions problem, a lock, a directory sitting where a file belongs) — never heals, so it is disclosed per file as not_indexed: true instead of blocking the call. When the bounded wait does run out, the index_updating refusal NAMES the paths it is still waiting on (up to five, and it says when it is showing only a prefix): a path still named on the next call is not watcher lag, and index_coverage will give it a verdict with the rule that proved it. The project root may sit anywhere inside the work tree (a monorepo member, a workspace crate): git's output is rebased onto it, so paths stay project-relative and a sibling project's changes never appear. A file git reports as deleted that is still on disk was untracked (git rm --cached), not removed: it is reported as change: "untracked" with no symbol rows, never as an API break. Other corrective misses (for example an unknown focus) carry did_you_mean suggestions.

Language packages

Language Extensions Package grammar
Rust .rs tree-sitter-rust
Python .py, .pyi, .pyw tree-sitter-python
TypeScript / JavaScript .ts .tsx .js .jsx .mjs .cjs tree-sitter-typescript / -javascript
C# .cs (skips *.Designer.cs, *.g.cs) tree-sitter-c-sharp
PHP .php .php3 .php4 .php5 .phtml tree-sitter-php
Ruby .rb .rake .gemspec, Rakefile, Gemfile tree-sitter-ruby

Ruby extraction is Rails-aware: association macros (has_many, belongs_to, has_one, has_and_belongs_to_many) emit a type reference to the associated model class (honoring class_name: and singularizing plural names), and mixins (include/extend/prepend) reference the mixed-in module.

Default-skipped directories include node_modules, vendor, __pycache__, .venv, .tox, dist, build, .next, .code-index, plus target/ (Rust roots only) and bin//obj/ (.NET roots only). Add exclusions via .code-index-ignore (same syntax as .gitignore); it cannot restore these default-skipped directories.

Binaries

Binary Purpose
code-index CLI: init, skill, query, index, watch, doctor, link, languages, plugin
code-index-daemon Long-lived watcher + RPC server
code-index-mcp MCP stdio bridge for AI agents
code-index-plugin-host Bounded worker that runs plugin packages

You normally only invoke code-index-mcp; it spawns the daemon for you, and the daemon spawns code-index-plugin-host when a plugin package is active. Keep all four together — the host is what enforces the bounds a package runs under, so a missing one is not a degraded feature.

CLI reference

code-index query prints one JSON document. Every content_in text reference, including nested and batch entries, is copied into content; unreferenced MCP content items remain in unreferenced_content_items. content_in records the original MCP location, not a pointer into this CLI document. Raw MCP transport keeps its split content shape.

code-index init                           # write only .code-index.toml, with detected
                                          #   languages enabled
code-index query <tool> '<json>'          # ask the index from a shell: the MCP tools,
                                          #   reachable by a hook, a pre-commit or a
                                          #   human. Exit 0 answered, 1 refused, 2 usage;
                                          #   a batch exits 1 only if EVERY entry refused
code-index index                          # one-shot index of the current project
code-index watch                          # initial index + live updates (foreground)
code-index doctor                         # diagnose: DB, schema, daemon, index freshness,
                                          #   resolver health, watch set, free disk space
code-index link add <name> <path> [--relationship <kind>]
                                          # add a link; infer worktree for the same Git repo
code-index link list [--json]             # list configured links + live daemon status
code-index link remove <name>             # remove a [[links]] entry
code-index approve [<request>...]         # grant what a repo's .code-index.toml asks for (a
                                          #   [[links]] entry, a raised size cap); no args lists
code-index languages                      # which languages are enabled, who owns
                                          #   each, and which are present but not
                                          #   enabled (with the command)
code-index languages enable <lang>...     # opt a language in
code-index languages owner <lang> auto|builtin|package
                                          # who extracts it; see "Languages" below

Add --root <path> to operate on a project other than the current dir.

Languages: enabled, and who owns each one

[languages] in .code-index.toml records language enablement and owner choices. Automatic first-party maintenance defaults on; its machine and project opt-outs are described under automatic maintenance.

[languages]
enabled = ["ruby", "rust"]   # the first-party languages this project opted into
ruby = "auto"                # owner: auto | builtin | package  (default: auto)
  • enabled is decided for you once. code-index init detects the languages in the tree and writes exactly those to .code-index.toml. The first daemon or code-index-mcp start in a project with no enabled key does the same — from disk plus any existing index, so an upgraded project keeps every language it already indexes — but writes it only to .code-index/languages.toml, inside the index state directory: an automatic start never writes into your project root and never dirties your git tree. It says so in project_overview.language_ownership.enablement (written_to, change_with). Automatic maintenance can extend a nonempty generated list when new file types need another verified first-party package. Set top-level automatic_plugins = false to keep a project's list fixed. A language excluded by the effective list is reported as language_not_enabled, with code-index languages enable <lang> as repair.
  • Precedence: .code-index.toml always wins. Its [languages] enabled is used whenever it is present; .code-index/languages.toml is read only while it is not. A missing key means the same thing whether the config file exists or not: use the automatic decision, or enable all languages until a first start records one. init and languages enable record an explicit list. link and languages owner can create the file without that key; package approval and automatic updates can extend an existing explicit list. language_ownership.enabled_source says which one is in force (config, state or none).
  • Release binaries contain no native language parsers. Rust, Python, JavaScript, TypeScript, C#, PHP and Ruby run through signed language packages. Text indexing remains part of the runtime. An unavailable or unapproved package is reported; there is no native fallback.
  • The owner setting: auto selects an enabled, approved, healthy package; package explicitly selects a package. Existing enablement and approvals are preserved on upgrade. A project explicitly pinned to builtin stays unindexed when this build has no builtin for that language. Run code-index languages owner <lang> auto to migrate that choice. Development builds may still opt into frozen native fallbacks.
  • Bundled first-party packages. A release archive carries the signed Ruby, PHP, JavaScript, TypeScript, C#, Rust and Python packages in code-index-packages/, beside the binaries; install.sh and install.ps1 install that directory beside them. code-index languages enable ruby adopts the bundled package without downloading anything: it is verified exactly as plugin install verifies any file (the signature against your trust store, which holds the key compiled into this binary, and the reserved-id rule), then installed, graded and approved through plugin add with the package's requested grant — the confirmation block is printed. code-index languages list and code-index plugin status show what is bundled, what was refused and whether this project approved it. On first start, enabled languages under auto adopt bundles signed by a key shipped in the binary through the same install and approval checks. Withdrawn keys and operator choices remain authoritative; a third-party bundle requires explicit approval.
  • A running daemon re-extracts the files whose owner changes and re-arms within a few seconds of the edit. project_overview.language_ownership.owner_switch and index_freshness.owner_switch say which languages moved and how many files were re-extracted. Offline indexing and startup reconcile a changed activation through an isolated full rebuild before replacing active facts.
  • First-party packages keep the plain ids (ruby, php, rust, …): their rows are byte-identical in lang to the builtin's, so filters, baselines and bridges do not move when the owner changes. Those ids are reserved — see plugin trust add --first-party below.
  • Compatibility: a code-index older than this release refuses a [languages] <lang> = … key. Only enabled (which every release parses) is ever written automatically.

code-index plugin — language packages (EXPERIMENTAL)

A plugin package (.cip) is a language extractor that runs inside code-index-plugin-host, an isolated worker process. The extractor is wasm with zero imports, so the guest as loaded has no way to name a syscall at all — no file, no socket, no clock. That property is structural and holds on every platform.

The worker is not a kernel sandbox, and we do not claim it is one. There is no namespace and no capability drop. Code that escaped wasmtime inside a worker could still read any file the invoking user can read; the cwd is private and empty, but absolute paths are unaffected by that. Network denial is a seccomp filter that denies a named set of syscalls, and it exists only on Linux x86_64 and aarch64 — on every other target there is no filter at all.

Two surfaces say so, rather than leaving you to know. code-index plugin status prints a containment line for this build's target, and project_overview carries plugin_activation.worker_containment whenever a plugin host is running. Both report filesystem: unconfined on every platform, and neither ever reports the network filter as enforced: a worker's kernel outcome reaches only that worker's self-report, which no shipped path enables, so the honest states are requested_unverified (a filter is installed and nobody upstream can confirm the kernel took it), unsupported (this target has none) and not_requested. An ABSENT block means no plugin host is running, or an older daemon — never that a worker is contained. _prdoc/guides/80-threat-model.md §6 enumerates exactly what an escaped guest could still reach, and §7 lists what that document could not verify.

A package must be signed by a trusted key: unsigned is refused, at install and again at every load, and there is no --allow-unsigned. Installing one grants it nothing; approval for a project is a separate, explicit step that names the capabilities being granted. Automatic first-party maintenance can approve verified first-party packages under its separate policy; see automatic maintenance.

Our own publisher key ships inside the binary (sha256:1cb03259a8c870b6db02360abd9351e17e67724d1f8c3509d85c4bf6b06fa72c, shown as [BUILTIN] by plugin trust list), so a package we publish installs with nothing to set up first. It extends no trust you had not already extended — forging that anchor means forging the binary — and it is revocable like any other: plugin trust remove <fingerprint> records a denial in your trust directory and the key stops verifying at the next load. Anchoring a THIRD-PARTY publisher is still a deliberate act: plugin trust add, with a fingerprint you obtained somewhere other than the key file.

Plain language ids are reserved for first-party packages. A package that declares [[languages]] id = "ruby" (or any id a compiled-in plugin stamps: rust, python, typescript, javascript, csharp, php, ruby) stamps its rows with that plain id, exactly as the builtin does. So it is refused — language_reserved, by name, at install and again at every load — unless the key that signed it is the one shipped with this build or one you anchored with --first-party (for a rebuild that publishes its own first-party languages). plugin trust list marks such keys [FIRST-PARTY].

# the one command: verify, show what approving does, ask once, do it all
code-index plugin add [<file.cip|digest>] [--grant requested|none|<c,...>] [--yes]
code-index plugin add <id>[@<version>]        # resolve through the distribution
                                              # catalog; the catalog IS the pin
code-index plugin add <https://…/pkg.cip> --sha256 <digest> [--signature-url <url>]
                                              # fetches the .cip AND the .cips
                                              # beside it; --sha256 is REQUIRED
                                              # for a URL and optional for a file
                                              # --registry-url <url|path> overrides
                                              # which catalog an id resolves in

# authoring
code-index plugin pack <dir> [--out <file>]   # build a .cip from a directory tree
code-index plugin inspect <pkg> [--json]      # print what a package declares
code-index plugin validate <pkg>              # parse + validate, printing any refusal
code-index plugin digest <pkg>                # install identity + extraction identity
code-index plugin sign <pkg> --key <keyfile>  # write the detached .cips beside it

# publisher keys and trust (user-scoped; no project)
code-index plugin key generate <keyfile>      # Ed25519 pair; prints the fingerprint
code-index plugin key fingerprint <key.pub>   # read one back; READ ONLY, no store
code-index plugin trust add <key.pub> --fingerprint <fp> --name <label>
code-index plugin trust add <key.pub> --fingerprint <fp> --name <label> --first-party
                                              # this key may publish RESERVED plain
                                              # language ids (ruby, php, rust, ...)
code-index plugin trust list                  # every anchor this machine holds
code-index plugin trust remove <fingerprint>  # takes effect at the NEXT LOAD
                                              # (a [BUILTIN] key is denied, not deleted)

# operating
code-index plugin install <pkg> --sha256 <d>  # into the user store, pinned by digest
code-index plugin install <id>                # same, resolved through the catalog:
                                              # --sha256 optional (the catalog pins),
                                              # and it needs NO project — this is
                                              # what `install.sh --with-plugin` runs
code-index plugin check <digest>              # run it on its own fixtures, record a verdict
code-index plugin check <digest> --repo <dir> # run it over THAT tree, record nothing
code-index plugin enable <digest> \
    [--grant requested|none|<c,...>] \
    [--capabilities <c,...>] [--bridges <b,...>]   # approve for THIS project
code-index plugin disable <digest>            # withdraw approval; stays installed
code-index plugin remove <digest>             # delete from the store and withdraw

# updating (see "Updating a package" below)
code-index plugin update                      # check every [update."<id>"] entry; apply
                                              # only where the AUTHORITY is unchanged
code-index plugin update --check              # check and record only; never applies
code-index plugin update <id>                 # newest version THIS host may install,
                                              # from the signed package channel

# the distribution catalog (read only; no project)
code-index plugin catalog                     # the catalog compiled into this binary
code-index plugin catalog <url|path>          # fetch/read it, VERIFY <source>.sig, report
code-index plugin catalog-merge --out <dir> [--base <channel>] [--include <release catalog>]
    [--package <x.cip> --url <u> --signature-url <u>]   # release tooling: build the package
                                              # channel (registry.v2.json + its v1 projection)

# state
code-index plugin status                      # installed, approved, granted, what is owed
code-index plugin doctor                      # diagnose the installation (read only)
code-index plugin retry                       # finish what an interrupted activation owes
code-index plugin rollback                    # switch back to the retained generation
code-index plugin gc [--dry-run]              # reclaim unreachable generations and bytes

add verifies the signature before it renders anything, shows the publisher under your label for the key plus the grant and the measured reindex domain, and asks once — a bare Enter is no. Given a URL it fetches the .cip and its .cips into a staging directory outside the store — delegating the transfer to curl, because the transport is trusted for nothing here — and then runs exactly those checks. --sha256 is required for a URL and optional for a file, and the difference is not caution: the signature already decides who published the bytes, but the bytes at a URL are chosen by whoever controls the URL and the network, so a validly signed but different package can be served. The digest pin closes that, and nothing else. enable refuses a package that has not passed check, and rollback executes no package at all. A signature says these bytes are the ones that key signed; it does not say they are safe.

catalog is the read-only leaf for the distribution catalog — the document that says which package versions exist, at which URLs, pinned to which digests. With no argument it reports the catalog compiled into this binary. Given a URL or a path, or with COSI_REGISTRY_URL / COSI_REGISTRY_PATH set, it reads that one and requires <source>.sig beside it, verified against the keys you anchor — so plugin trust remove reaches this door exactly as it reaches an installed package. There is no flag that accepts an unsigned catalog: a catalog decides which bytes plugin add <id> fetches and which digest they are pinned to, so an unsigned one moves the pin. The compiled-in catalog is the one exception, because forging it means forging the binary. It prints the source, the byte count, the fingerprint that verified it, the platform state and the plugin list, and it exits non-zero on any refusal — which is how the release checks its own signature through the same code path a customer takes.

The operator guides under _prdoc/guides/ cover packaging, the capability model, recovery and the threat model in full. The distribution catalog lists the packages available for this build. After its signed assets are published, plugin add <id> resolves them without copying a URL. Release binaries contain no native language fallbacks; auto selects an enabled, approved, healthy language package. Explicit builtin owner choices remain authoritative and need migration.

id claims notes
de.h-dv.xaml .xaml binds x:Class / Click= into the paired C# code-behind
de.h-dv.timeline .dataset .xsql .shd .lgd two languages out of one extractor; resolves inside a definition file only
de.h-dv.ruby .rbx .rb .rake .gemspec, Rakefile, Gemfile shared canonical Ruby extractor; native domains require approved displacement and package ownership. Rails derived names require --derived-names
de.h-dv.svelte .svelte the TEMPLATE half of a Svelte single-file component — markup, mustaches, {#snippet} and {@render}. It claims .svelte and nothing else: .svelte.ts and .svelte.js are runes modules the TypeScript and JavaScript packages already read correctly. One capability, same_file_candidate
de.h-dv.vue .vue a Vue single-file component: the template's PascalCase component tags, @event handlers, bindings, v-for sources and {{ }} interpolations, and each <script> / <script setup> body handed to the package's TypeScript or JavaScript extractor (setup is file-scoped, a plain <script> a module). Any other <script lang> is reported unsupported. Capabilities same_file_candidate,exported_candidate
de.h-dv.php .php .php3 .php4 .php5 .phtml canonical PHP extraction and package-owned Composer module layout
de.h-dv.javascript .js .mjs .cjs .jsx separate JavaScript and JSX grammar lanes
de.h-dv.typescript .ts .tsx separate TypeScript and TSX grammar lanes
de.h-dv.csharp .cs canonical C# extraction, including granted extension methods; generated .Designer.cs and .g.cs files excluded
de.h-dv.rust .rs canonical Rust extraction
de.h-dv.python .py .pyi .pyw canonical Python extraction and explicit import/export metadata; fixture conformance needs --capabilities lexical_local,pytest_fixture

To select a bundled language package, run code-index languages enable <lang> and approve its requested capabilities; auto selects it in release builds. languages list and plugin status report the selected owner and any refusal. Release builds use --no-default-features and require installed, approved language packages. Cargo development builds offer frozen native fallbacks as test oracles (only Ruby by default); individual builtin-<lang> features select them explicitly.

Every release attaches <id>-<version>.cips beside each .cip, and this release's publisher key is compiled into the binaries, so a pinned install works on a machine that has anchored nobody. Download the .cips and keep it beside the .cip — an install without it is refused with signature_missing. If a package you installed has stopped loading, read _prdoc/guides/80-operator-recovery.md §1.2, which is the repair for a package that really is unsigned: one you built yourself, or one whose publisher ships no .cips.

EXPERIMENTAL: the plugin ABI and the .cip format are not stable across releases yet.

Updating a package

A missing .code-index.toml uses defaults. An invalid file refuses startup in the CLI watcher, daemon and MCP server, and refuses an indexing walk before activation or row changes. The shared error names the file, TOML position and accepted configuration keys. Fix the file before restarting; invalid ignores never become a broader walk. A walk policy that cannot load the config also blocks live file events. Every index.extra_ignores entry is validated at load using gitignore syntax; an invalid pattern is refused with its list position and value. languages.enabled accepts well-formed language ids even before their package is installed. Loading warns on stderr and doctor reports names no builtin or installed package declares, with nearest known names: install the declaring package or remove the entry. Other enabled languages continue indexing. Malformed ids are refused. code-index languages enable still refuses unknown names without editing the file. Unavailable languages have an ownership row even when they have no indexed files. Malformed trusted .code-index/languages.toml is reported as an error. Routing fallbacks disable package enablement while that error persists.

Older configs may contain logging.level, logging.format, workspace.name or plugins[].file. These keys remain accepted for compatibility and have no effect. Loading emits one diagnostic per file and key set in a process; repeated loads do not repeat it. The diagnostic names the keys; code-index doctor reports them too. Remove them. Use --log (or CODE_INDEX_LOG) and --log-json for logging, and code-index plugin add <file> to install a package.

Automatic first-party language plugins are enabled by default across all your projects. The indexer and daemon detect languages, install compatible signed first-party packages, enable them and update them with affected reindexing. Upgrading the core binaries together is sufficient; the installer needs no project list.

To turn maintenance off for all projects using your operator store:

code-index plugin update --automatic=false

An explicit operator policy lives outside repositories, in $CODE_INDEX_HOME/automatic-plugins.toml (normally ~/.code-index/automatic-plugins.toml). Every project opened later uses it; the installer does not need a project list. Run code-index plugin update --automatic to turn maintenance back on, or code-index plugin update --check to inspect candidates without applying them.

The daemon reconciles after startup and checks for new file types once a minute. Checks for the same file types are limited to once per day; a core upgrade or new file type triggers an earlier check. Failed checks retry after five minutes. Svelte/Vue detection also makes the JavaScript and TypeScript producers available. Installed and bundled packages are used first, so local packages remain usable offline. Downloads and conformance work are serialized across projects and package bytes are reused from the shared store. Signature, host compatibility and conformance checks precede activation; failed activation retains the working version.

With automatic maintenance enabled, a nonempty generated language list grows when files need another verified first-party package, including packages such as Vue that are offered by the registry rather than bundled. Each project updates its own state and approval, even when projects share the package store. --check reports missing language entries without changing them. An explicit manifest language list is never widened. Empty language lists, builtin owners, requested digest pins, per-package update settings, publisher withdrawals and disabled packages take precedence. To keep a nonempty list fixed, set automatic_plugins = false for the project. Approved packages excluded by the list remain installed; plugin status and project_overview identify that exclusion. Bare plugin enable <digest> keeps an existing grant; --grant none explicitly withdraws its answering authority. For a fully granted first-party plugin, an upgrade also grants newly requested capabilities. Restricted grants and publisher changes still require approval. To opt one project out, set automatic_plugins = false at the top level of its .code-index.toml after upgrading that project's code-index; older binaries reject unknown configuration keys. plugin status reports both the machine policy and whether it applies to this project.

Core upgrades update bundled packages per project. When a project next starts its daemon or MCP server, or runs code-index index/watch on its default index, it checks the signed first-party packages beside the new core. A newer bundled version replaces its approved version after signature, host-compatibility and conformance checks. The installer does not need to know the projects; reconciliation uses local bundle files without fetching packages or running a timer. Each project's approval record makes subsequent starts a no-op. A newer installed package is kept when the bundle is older.

This preserves explicit builtin owners, other publishers' selected packages, withdrawals and restricted grants. A bundle needing more authority is reported instead of approved silently; plugin update <id> handles that decision. An explicit rollback to an older approved digest is also retained on restart. Changed extractor bytes may reindex the files they claim, as part of the core upgrade. An explicit package owner permits upgrades of its existing approval.

Approvals are keyed by digest, so a new version of a package is a package this project has not approved. plugin update is how that gets answered for independently released packages without retyping four commands.

Read C1's not_graded disclosures before enabling a grant. In particular, bridge_authority is not recorded or compared; see the conformance contract. That contract also explains why a narrower saved run yields conformance_not_run, including when it failed, and when a covering failed run yields conformance_failed. Run the exact check command in the witness.

You do not have to type this from memory. code-index plugin add <URL> --sha256 <digest> prints the stanza for the package it just installed, with the id and the URL already filled in, on update_config lines — that command is the one moment the tool holds both halves, so it offers them there rather than in a paragraph. Where an entry already exists it says so instead, with the auto_apply in force, and it names both URLs when the configured source is not the one you installed from. plugin add <FILE> prints no source — there is no URL to put in one — and says that is why.

It is opt-in per package, in the project's own .code-index.toml:

The daemon checks configured entries in the background after becoming ready, then once per day while it stays running. Each project's operator store records the attempt, so restarting a daemon does not repeat a failed/offline fetch within 24 hours. Projects with no [update] entries make no per-package update requests; automatic first-party maintenance follows its separate policy. The matching code-index CLI must be installed beside the daemon; its existing signature, conformance, grant and activation checks also govern background runs. auto_apply = false records findings only. A changed extraction identity still requires deliberate confirmation through code-index plugin update <id>. plugin status and the MCP update notifications report completed findings; the daemon log names the background command's detailed log in the operator store. An explicit CLI update remains available during the cooldown.

[update."de.h-dv.xaml"]
source     = "https://git.h-dv.de/api/packages/h-dv/generic/code-index/<release>/de.h-dv.xaml-<pkgver>.cip"
auto_apply = false   # the default: CHECK AND NOTIFY ONLY

That is the real shape of the URL here — <release> is the code-index version the package was published with and <pkgver> is the package's own version, both as they appear in the release notes. <release> carries NO leading v: the tag is v0.27.1 and the path segment is 0.27.1, because the publishing step strips it (PKG_VERSION="${TAG#v}"). Measured — .../code-index/v0.27.1/de.h-dv.xaml-0.2.0.cip answers 404 and .../code-index/0.27.1/de.h-dv.xaml-0.2.0.cip answers 200. The same URL with .cips, .cip.sha256 and .cip.digest.txt appended serves the signature, the checksum and the digest record; the package_digest line of .cip.digest.txt is what --sha256 wants elsewhere in this file (the other line, extraction_identity, is not).

The catalog is the OTHER door, and it is a separate key:

[update."de.h-dv.timeline"]
from       = "registry"   # and NO `source` — the catalog resolves the id
auto_apply = false

from says which door the stanza uses, and the two are mutually exclusive: "url" — the default, and the state of an unstated field — requires source, and "registry" requires source to be absent. A stanza naming both is refused, quoting both values it read; a from = "url" stanza with no source is refused too, and that refusal names the other door. An operator who wrote both has not said which they meant, and choosing for them would be this tool choosing where your code comes from.

It is a key rather than a value inside source, for a measured reason. The test used to be source.starts_with("registry") — and source = "registry.example.com/pkg.cip" is a URL an operator may really write. It was swallowed by that prefix and served the EMBEDDED catalog instead of that server, silently. A sentinel living in a field whose values are hostnames cannot be told apart from a hostname; two keys cannot collide.

The registry update door resolves the package id through the signed catalog this machine is configured with — COSI_REGISTRY_URL / COSI_REGISTRY_PATH, or the live package channel — and a repository cannot name a different catalog. It changes only WHERE the candidate bytes come from: everything below is evaluated the same way, so a catalog offering an older or equal version is stopped by the same clause a URL would be. It does add one check the URL door has no way to make — the catalog names the version's package_digest, and bytes whose digest is not that one are refused as unreachable rather than assessed.

source is a URL the .cip is fetched from; the .cips beside it is fetched too. There is no --sha256 here and that is deliberate — a pin would have to be edited on every release, so the digest is not the control. The control is that the AUTHORITY must be unchanged, and plugin update applies a candidate only when all of the following hold against the version whose grant is in force:

  • the same publisher key, by fingerprint, out of your trust store — "the signature verifies" is not the test, "the same publisher" is;
  • a grant that is a subset of the one already granted (no new capability, no new bridge, no derived_names);
  • no new [[displaces]] row;
  • the same package id;
  • the digest was not withdrawn here by plugin disable;
  • the candidate's [package] version is strictly NEWER than the one in force, by semver precedence and not by string order — 0.10.0 is newer than 0.9.0, and build metadata (1.2.3+a) is not part of the comparison at all;
  • and extraction_identity unchanged — not an authority question but a cost one, since moving it reindexes every file the package claims.

Anything else is recorded, reported with the specific reason that stopped it, and left for a human. The reasons are codes, not prose — update_publisher_changed, update_grant_increase, update_displacement_added, update_package_id_changed, update_version_not_newer, update_version_not_comparable, update_extraction_identity_changed, withdrawn_here, plus update_deferred_by_check and update_apply_failed for the two non-authority stops — and each carries a witness naming the actual capability, bridge, fingerprint, id or version pair. They appear on plugin update, on plugin status, on plugin doctor, and in project_overview's package_updates block as blocked_by {reason, witness} pairs.

A version that is not newer is refused, and an EQUAL version is refused too (#255 — until it was fixed, an older package by the same publisher satisfied every other condition and applied: measured at 0.0.1 over an installed 9.9.9, reported applied). Two codes, because they are two different statements:

  • update_version_not_newer — measured. Either a downgrade (a rollback: the same publisher, the same grant, an older version) or an equal version with different bytes (a republish — the extractor changed without a version bump). Both refuse, because "newer" is the entire warrant for acting with no operator present, and the witness says which of the two it is.
  • update_version_not_comparable — not measured. One of the two versions is not one this host can order, so no comparison happened. This cannot arise from a published .cip (the manifest validator is the version parser, so anything that parsed can be ordered), and it refuses rather than falling through precisely so that it never becomes an unattended apply nobody graded.

To take a republished or older version deliberately, plugin add is the command — it asks.

Channel updates use the daemon's background updater. The operator's automatic policy needs no per-package entries. Without that policy, configured [update] entries are checked after startup and daily; auto_apply = true permits application under the narrower per-package rules above, including the extraction-identity stop. Projects with neither policy nor configured entries make no update requests and report that no update check has run. An explicit code-index plugin update runs immediately, independently of the cooldown.

To update manually instead, or to take a version update refused, plugin add is the one command: it verifies, shows what the new grant would do, and asks once.

code-index plugin update --check    # what would happen, and why not
code-index plugin update           # apply where the authority is unchanged

One package, from the signed package channel. First-party packages are released on their own tags (pkg/<id>/v<version>), without a core release, and listed in a rolling signed catalog — the package channel. code-index plugin update <id> reads it and installs the newest version whose requires_host admits this code-index release:

code-index plugin update de.h-dv.ruby --check     # what is available
code-index plugin update de.h-dv.ruby             # apply it
code-index plugin update de.h-dv.ruby --catalog ./mirror/registry.v2.json
  • The catalog's .sig is verified against your trust set, and so is the package's .cips; the fetched bytes must match the catalog's pin.
  • Where nothing about the authority changed it applies, exactly as an auto_apply entry would. Where the grant grows, a displacement is added or the extraction identity moves, it asks — the plugin add confirmation, with --grant / --yes as there. A different publisher, a different id, a withdrawn digest or a version that is not newer is refused by its code.
  • Atomic. Nothing is written until the candidate has been fetched and verified; C1 runs before the grant is written, and an activation that fails puts the approval record back. The version in force stays active.
  • Approval writers for the same project run one at a time, including CLI mutations, MCP plugin_add, manifest/link grants and startup adoption. A competing command waits up to 120 seconds for the kernel lease, including while the holder makes progress. If still blocked, plugin_mutation_busy names the last observed holder's operation and PID and the time waited; retry after that writer finishes. plugin status and plugin update --check remain available, and other projects can update independently.
  • Without a running daemon, activation builds and resolves the index in the foreground and the command waits for promotion. A large project or full reindex may take several minutes; activation_foreground announces this before the activation work starts.
  • Offline is a refusal, never "up to date": the catalog compiled into the binary is never used here (it cannot know of a later release). Point --catalog at a local mirror (the .json and its .sig) to update without the network. Default source: COSI_REGISTRY_PATH, then COSI_REGISTRY_URL, then the package channel.

A package may declare which code-index releases it needs: [package] requires_host = ">=0.33.0". A host outside the range refuses it by name at install AND at activation (manifest.abi_unsupported, naming the range and this host). After a code-index upgrade that moves you out of an installed package's range, plugin update <id> fetches a version that admits the new host.

The link subcommands manage the [[links]] array in .code-index.toml without you having to hand-edit TOML. Comments and whitespace in the file are preserved; writes are atomic; validation matches the MCP runtime exactly (reserved name, duplicates, self-cycle, link-link path collisions all caught at write time).

Auto-detected project root

If you don't pass --root, code-index walks up from your CWD looking for:

  1. .code-index.toml — innermost wins. Drop one in any directory to pin that as the root regardless of any marker above it. code-index init creates one for you.
  2. .git/ — innermost wins. Stops the walk: a workspace at repo/ with sub-crates at repo/crates/foo/ is correctly detected as repo/ even when you run code-index from inside a sub-crate. A stray ~/.git (dotfiles repo) won't trip detection because a closer .git/ always wins.
  3. Outermost Cargo.toml / package.json / pyproject.toml / composer.json / *.csproj / *.sln — only when no .git/ is found anywhere up the chain. Catches non-git workspaces.

The walk is bounded at $HOME so a marker far above your home directory can't poison detection.

Workspace links — querying multiple projects from one MCP entry

A customer install that extends a base product, a fork that needs to compare against upstream, an app that sits on a vendored library — common patterns where one Claude / Cursor / Cline session needs to query two indices at once.

Drop a [[links]] block into the primary project's .code-index.toml:

[[links]]
name         = "mainproject"                       # routing key for tool calls
path         = "../../code/mainproject/24.0/source"  # absolute, `~/…`, or relative to this file
relationship = "base"                           # base | dependency | fork_source | sibling | worktree
description  = "mainproject 24.0 — base product"

A link declared in a COMMITTED manifest is a request, not a setting: a repository you clone cannot make the daemon index and serve a directory of yours by naming it. The server attaches it once you grant it — code-index approve link:mainproject in the primary project (bound to the path it points at now; re-pointing it asks again) — and until then says so in the reply and in code-index doctor. code-index link add is you typing the link, so it grants as it writes. The same rule holds for an [index] max_file_size_mb above the built-in default (code-index approve index.max_file_size_mb); lowering it needs nothing.

link add creates a missing config without recording a language decision; adding or removing links preserves the enabled set and its source. With no --relationship, another checkout sharing the primary's Git common directory gets worktree; other targets get dependency. An explicit --relationship always wins. Worktree links are queried by name and excluded from default fan-out. Nested checkouts are excluded by the outer index's walk, so the overlap warning applies to ordinary nested directories. Both add and remove print the entry's name, path, relationship and config file; remove also reports any approval it withdraws.

Then your existing MCP client config keeps one entry pointing at the primary root; the server attaches a daemon for each link at startup. Tools route across projects three different ways:

// (a) Name/phrase searches FAN OUT by default — primary + every
//     available link. Each hit carries a `project` tag.
search_symbols { "query": "mainprojectService" }
// → [{ name: "mainprojectService", project: "primary", … },
//    { name: "mainprojectService", project: "mainproject", … }]

// Pin a single project by passing its name:
search_symbols { "query": "mainprojectService", "project": "mainproject" }

// (b) Path-shaped tools (file_outline, read_code, get_dependencies)
//     AUTO-ROUTE: an absolute path picks the owning project by
//     longest root-prefix; relative paths default to primary.
file_outline { "path": "/abs/path/under/mainproject/foo.cs" }   // → mainproject
file_outline { "path": "src/foo.rs" }                         // → primary

// (c) Symbol-id tools (get_symbol, find_references, find_callers,
//     find_callees) are project-local: pass the `project` from
//     the row that produced the id.
find_references { "symbol_id": 42, "project": "mainproject" }

project_overview on primary lists every linked project with its index health, and the MCP info.instructions block names each project + canonical root on initialize — so a fresh agent discovers the topology without any extra calls.

What it does: fan-out + path auto-routing + isolation, structured errors when a project name is unknown (project_not_found with did_you_mean), configured-but-unavailable (project_not_available — daemon failed to spawn or path was unreachable), or a path lives outside every indexed root (path_outside_known_roots with the known roots in did_you_mean).

What it doesn't do (yet): cross-project ref resolution (find_callers on a mainproject symbol won't surface h-dv callers). Cross-project definition, override and call-into-base queries remain design proposals; they are not available through the current tool surface.

See _prdoc/missions/I011-workspace-links.md for the full design.

Architecture

See ARCHITECTURE.md for the design rationale and the diagrams. See CONTRIBUTING.md to build, test, and add a language plugin. Operator-facing guides — package authoring, the extractor ABI, the capability model, grammar-build reproducibility, recovery and the threat model — live under _prdoc/guides/; specs and adaptation reports live elsewhere under _prdoc/.

Status

Current release: v0.41.53 (changelog).

Per-release detail — what shipped, what it fixed, and the measurements — lives in the release notes: git.h-dv.de/h-dv/code-index/releases. Design records for each mission are under _prdoc/missions/.

For resolved_by rule meanings and false-positive limits, read the resolution guide (MCP: code-index://docs/resolver-degradation).