ccx — Query an Indexed Codebase (Semantic Search + AST Grep + Symbol Navigation)
ccx is the client CLI for CocoIndex Code Plus. It queries a codebase that a
remote query server has indexed into Postgres + pgvector — semantic search,
AST structural grep, symbol definitions/references, read-only file access, and
(where enabled) a cited written answer to a question — over HTTP. The CLI holds
no index and needs no license; it just talks to a server.
When to reach for ccx
Reach for ccx first — before grepping and reading files — whenever the task is to understand or find code. The server holds a resolved index of the whole repo (and of other refs and other repos), so it answers in one call what local tools reach only after a chain of greps, and it does not miss because a search term was guessed wrong:
- Fuzzy / conceptual search — you don't know the exact term, so text grep
has nothing to anchor on ("where is retry handled?", "how are embeddings
stored?") →
ccx search. - Structure-oriented grep — the question is about code shape, not text
lines: a def vs. a call, a
catchthat re-throws the same variable it caught, everyisinstanceon a type, a nested generic that>>breaks for regex →ccx grep. - Symbol navigation — "where is
Xdefined?", "who calls / imports / usesX?": a resolved cross-file answer (alias- and re-export-aware, not a text match) →ccx defs/ccx refs. Covers Python, TS/JS (incl. TSX), C/C++, C#, and Rust. - Large / remote / multi-repo corpus — the repo isn't checked out locally,
you need a branch or tag other than your checkout, or the question spans
several indexed repos (
--repo, repeatable forsearchandask;--git-ref). - A question, not a lookup — the deliverable is a written, cited
explanation ("how does re-embedding get decided, end to end?", "compare auth
in these two services"), or the question is too broad for one search or
pattern →
ccx ask(see below).
Local tools stay the right choice for exactly two things: reading a file that
is already at hand, and a plain literal-identifier lookup in a small repo you
have checked out — local rg answers that directly, and a ccx grep with no
structure (a bare identifier, no metavariable) just floods unstructured hits.
(But when the identifier question is really a symbol question — its
definition, or its true use sites rather than every textual occurrence —
ccx defs / ccx refs beat both.)
Repo & ref scoping (applies to every query command)
- Repo auto-detection. Commands auto-scope to the repo of the current git
checkout, detected from its
originGitHub/GitLab remote (resolved to an<owner>/<repo>name). Override with--repo <owner>/<repo>(repeatable forsearchandask, up to the server's per-search cap). Without such an origin the command errors with guidance rather than guessing — pass--repo;ccx reposlists the indexed repos you can target. There is no global "search everything" mode. - Ref defaulting. Every query command is ref-scoped, and
--git-refis optional everywhere: when omitted, the server uses your checked-out branch if it's indexed, else the repo's default branch — and prints aUsing git ref …note to stderr so you know which ref answered. Trust this default; there is no need to runccx git-refsfirst just to discover a ref. Pass--git-refonly to target a different ref — a bare branch/tag name works (main,v1.2); the qualifiedheads/<branch>/tags/<tag>form is only needed when a branch and tag share a name. An unknown ref errors with the list of indexed refs. - The indexed ref is a commit snapshot. Results describe the code at that
commit, not your working tree — a
Using git ref …note naming your own branch still means the commit the index holds for it. So when your checkout has uncommitted or unpushed changes, every path and line number can be stale, and a symbol you added since that commit is absent — that is not evidence it doesn't exist. Read the note, and confirm a specific location in your working tree before acting on it; when you need the exact commit that answered,ccx git-refsprints it per ref. Keep querying on a dirty checkout, though: locating existing code — the common case — survives a line-number offset. - CWD subtree scoping. Run from a subdirectory of the checkout and
search/grepdefault--pathto that subtree (a stderr note names the glob). To cover the whole repo, run from the repo root or pass--path '*'. Don't mistake subtree-narrowed emptiness for "not in the codebase".
Semantic search
Describe the concept, behavior, or functionality to find — not exact syntax.
ccx search how are vector embeddings stored # auto-scopes to the current repo + branch
ccx search user authentication flow
ccx search error handling retry logic
- Scope.
--repo <owner>/<repo>targets another repo — repeat it to search several at once (one cross-repo-ranked list);--git-ref <ref>targets a non-default ref (single-repo scope only). - Filters.
--lang <language>restricts by source language and--path '<glob>'by path — both repeatable. - Results. Ranked by relevance — the most relevant come first, so if the
top hit already answers the question, stop there; don't fetch more just to be
safe.
-k/--top-k <N>returns more (default 5) and--offsetpaginates — raise-konly when every result still looks relevant (then there are likely more). - A hit shows that code talks about your question, not that it runs. Search ranks by resemblance to your words, so a doc, a default, a test, or an older or fallback implementation can outrank the code that actually does the work. "Where is X handled?" is settled by the hit itself — read it, stop if it does X. "Which code actually does X?" is settled by how the path uses the hit — as the thing it calls, or only as a fallback or one registered option — never by how well it reads. A caller already in the results may show it; otherwise see Settling what runs below.
ccx search "rate limiter" --repo acme/api --repo acme/worker
ccx search foo --repo cocoindex-io/cocoindex --git-ref main -k 10
ccx search parse config --lang python --path 'src/**'
Each result prints [score] <repo> <file> (Lstart-Lend) and the code. To read
more context around a hit, open the file with your normal file tools (or, for a
ref you don't have checked out, ccx read-file — see remote-access reference).
AST structural grep
ccx grep matches a by-example pattern against the code's syntax tree (AST),
not text — so it ignores formatting and never matches code inside comments.
Requires -l/--language.
ccx grep 'foo(\*)' -l python # calls to foo(...), any arguments
ccx grep 'def \_(\*) \*:' -l python # every function def (async, decorated, `-> T` too)
ccx grep 'isinstance(\_, \_)' -l python --path 'src/**'
The essentials, in one breath: write the code you're looking for, and replace the
parts that vary with metavariables — \ is the only special character. Two
anonymous forms cover most patterns:
| Form | Matches |
|---|---|
\_ |
exactly one node, any — a single slot (a receiver, one operand) |
\* |
a run of zero-or-more sibling nodes — the default inside ( )/[ ]/{ } (\+ one-or-more, \? optional) |
\/re/ |
one node whose text matches the regex re (e.g. \/get_.*/) |
\NAME |
like \_, but named — only needed to report the capture, or reused later to require equal text (backreference) |
\{{ INNER \}} |
a node that contains INNER somewhere inside (any depth) |
Inside brackets, prefer \*: an argument list is several nodes, so
cached_fn(\_) matches only single-argument calls while cached_fn(\*) matches
them all. Reach for \NAME only when the name does work:
ccx grep '\_.filter(\*).map(\*)' -l rust # a .filter(...).map(...) chain, any receiver
ccx grep 'catch (\E) \{{ throw \E \}}' -l java # catch that re-throws the SAME var (backref \E)
ccx grep 'DenseMap<\_, \_>' -l c++ # nested generic; structural, so >> just works
Two mistakes to avoid (observed in real agent usage):
- Never escape literal code — including inside strings.
\*introduces* pattern constructs; it is not a shell/sed/regex escape.class Call(\_):is right,class Call\(\_\):is wrong (\(…\)is the metavariable delimiter, so the escaped parens become a metavar → silently matches nothing). The same trap bites string content: write"..", not"\.\.". - Matching is at lexer-token boundaries — a string literal is one atomic
node.
\*and\NAMEcan't reach inside it, and a literal string in the pattern matches only the full literal:open("config")does not matchopen("app_config.yaml"). For partial string content use a regex metavar whose regex covers the quotes:open(\/".*config.*"/). And to find code that handles a concept — not one exact literal — reach forccx search, not a string-literal grep.
An empty result is information, not a near-miss. Structural match is literal about structure — a wrong guess returns nothing rather than something fuzzy. So when a grep comes back empty, loosen the structure, don't abandon it:
- Replace the parts you're least sure of with
\_/\*/\?, or a name you're unsure of with\/re/(e.g.\/get_.*/(\*)). - Unsure whether
Xis defined or only called here?X(\*)matches both the def header and every call site. - Still nothing → the shape genuinely isn't there; pivot to
ccx searchfor the concept.
(Wrapping the whole pattern in \{{ … \}} does not broaden a top-level
match, and dropping to a bare identifier with no metavariable just floods hits.)
Key model: a pattern matches a fragment, child-aligned; incidental trailing
;/, are ignored, but closers (), }) are significant. The output shows
exactly the span the pattern covers — extend the pattern to see more:
def parse_config(\*) \*: prints only the header, while
def parse_config(\*) \*: \* prints the whole function including its body. That
\* before the colon absorbs a -> T return annotation — def f(\*): matches
only un-annotated defs (zero hits in a typed codebase), a silent miss. For the full
pattern language, verified recipes for common queries, and the gotchas (why
try \{{ … \}} needs the :, qualified names, fragment spans), read
references/grep-syntax.md before writing non-trivial
patterns.
- Pagination.
-k/--limit(default 100) and--offset(a skip count); a truncation note on stderr means there are more matches.
Symbol navigation (ccx defs / ccx refs)
Where is a symbol defined, and who uses it — answered from a resolved
symbol graph the indexer builds (cross-file, alias- and re-export-aware), not
from text matching. Covers Python, TS/JS incl. TSX, C/C++, C#, and Rust;
for other languages fall back to grep/search.
ccx defs QueryService # definitions of the base name
ccx defs db.Repo.find --qualified-name # exact dotted qualified name
ccx defs Config --kind class --lang python # filter: --kind / --lang / --path
ccx refs QueryService # uses, by base name (broad recall)
ccx refs src/db.py Repo.find # uses of EXACTLY this definition (see below)
ccx refs QueryService --role call # only calls (roles: call, import, type_use, …)
The canonical flow is defs → refs, a copy-paste. Each ccx defs row's detail
line ends with a paste-ready command — uses: ccx refs PATH ENTITY_ID (carrying
--repo/--git-ref when you scoped explicitly); run it verbatim, adding
--role and friends as needed:
$ ccx defs find
src/db.py:42:4 [method] python:db.Repo.find
lang=python uses: ccx refs src/db.py Repo.find
$ ccx refs src/db.py Repo.find --role call
Run the printed command — don't compose the second token yourself: the
headline's pack-tagged dotted name (python:db.Repo.find, module-qualified) is
not the entity id (Repo.find, file-relative). A bare ccx refs NAME is the broad alternative
(every definition with that unqualified name, plus unresolved mentions). If a
single argument looks like half of a forgotten pair (a path, or a dotted /
#-suffixed id), refs errors with the fix instead of running a broad query
you didn't intend; --base-name forces name mode when a base name genuinely
contains such characters.
Reading refs output — each row carries:
- a role (
call,import,type_use,inherit,implement,field_access,alias,decorates,use) — filter with--role; - a resolution:
resolvedis a definite reference;ambiguousmeans several candidates survived and each is its own row (the tool enumerates rather than guessing — so one source position can appear on several rows, and row count ≠ site count);name_onlyis a mention whose target couldn't be resolved (external import, dynamic receiver) — listed with the kinds it could have been (--no-include-unresolveddrops these); - possibly a
via_aliasmarker: a supplementary row for the alias/re-export hop the primary reference went through. Resolved rows print their target as the same two-tokenPATH ENTITY_IDform, so any target you see in output pastes straight back intoccx refs.
Check the stderr coverage note before trusting absence. It reports when the
symbol index for this ref is not built, skipped (ref too large to resolve),
partial (some files failed to parse), or lags the ref head — in every one of
those, a missing symbol may simply be unindexed. Absence is not completeness.
A stale exact target (definition renamed/removed since the defs call) errors
with target_not_found — re-run ccx defs for a current target.
A clean coverage note still does not make "No references." a proof. Two limits sit below coverage:
- An exact target (
ccx refs PATH ENTITY_ID) lists only resolved uses. A use the resolver could not commit — an unknown receiver, a module two source roots provide equally, an import across a source root the index could not infer — is a~name(name_only) row, and only the broad formccx refs NAMEshows it. Before concluding a symbol has no callers, runccx refs NAME --role calland read the~namerows as candidate uses. - Python source roots are inferred from the repo (package markers and
the repo's own absolute imports), never from build config. A namespace
portion the repo never imports absolutely stays unmerged, so a relative
import across it shows as
~name; treat such rows as real uses to verify by reading the file.
Settling what runs. To confirm a candidate is the live path, look at its
uses, not its text: ccx refs <candidate> lists them, and reading one gives the
verdict. A plain call on the path confirms the candidate. x or candidate()
means the path's value comes from x, so the candidate is only a fallback — the
answer is upstream, in whatever supplies x; do not report the fallback as the
decider. An entry in a registry or dispatch table means configuration picks, so
name the key that selects it. Where refs can't see (string keys, dynamic
dispatch, an uncovered language), ccx grep the call or registration shape
instead; and when the question names an entry point, ccx defs <entry> and
walking down reaches the live code without ever weighing the decoy.
Ask a question, get a cited answer (ccx ask)
Ask first when the deliverable is an explanation rather than a location:
one ccx ask call replaces a chain of searches. Every command above returns
material — hits, matches, symbol rows — for you to read; ccx ask returns a
written answer with citations, because a server-side agent runs that chain
for you, searching, grepping, reading files and following symbols, then writes
up what it found. Reach for it on "how does X work end to end?", "why does Y
happen?", "walk me through the release flow", "compare A and B" (above all
across repos), and on any question too broad to reduce to one search or
pattern. Scoping is the same as search: the current checkout by default,
--repo repeatable, --git-ref for a single repo.
ccx ask "how does the indexer decide what to re-embed?"
ccx ask "compare how these two services authenticate" --repo acme/a --repo acme/b
What it costs: ask runs seconds to minutes, where search, grep, defs
and refs answer in a second or two — so when the deliverable is a location
or code to read (which file to change, where a symbol lives, its call
sites), stay with those. Run one ask at a time (the server caps concurrent
agentic requests), and do not wrap it in a shell timeout — the server
enforces its own deadline (deadline_exceeded below), and macOS ships no
timeout command. A question already answered — by anyone, even rephrased — may return
instantly from the answer cache.
- Read it as evidence, not proof. Citations look like
[s0:path#L40-L52];s0resolves on stderr ass0: <owner>/<repo> @ <ref> (commit <sha>), and the line numbers are at that commit. Before acting on a claim, open those lines at that commit —git show <sha>:<path>, or your working tree when it is at that commit; for a repo you don't have,ccx read-filewith that--repo/--git-ref(the ref's indexed head — the same commit unless the index has moved since). A claim its lines don't support is dropped, not repeated. --jsonemits the exact response model (answer,resolved_scopes,usage;usage.result_cache_hitsays whether the cache answered);--statsadds a one-line cost/reuse summary on stderr.- It may be off, busy, or out of time.
agent_query_unavailablemeans the deployment hasn't enabled it: answer with the other commands, tell the user their platform team decides, and don't try again this session.agent_query_busy(concurrency cap) anddeadline_exceeded(the investigation outran the server's deadline): fall back to the other commands for this question — at most one later retry, never a loop.
Reading & listing files at a ref (remote / cross-ref)
ccx read-file and ccx find-files fetch file contents and paths at an indexed
ref. They're for code you don't have on disk — a repo not checked out locally,
or a different ref than your working tree. When the repo is checked out at
the ref you care about, use your normal file tools instead (faster, same bytes).
Full usage: references/remote-access.md.
Repo & ref metadata
ccx git-refs [<owner>/<repo>] lists what's indexed — a repo's refs and their
commit shas ((default) marks the default branch). Reach for it to target a
non-default ref, or to see which commit a ref is indexed at — not as a routine
pre-flight; the ref default already handles the common case.
Note the two different senses of "ref": ccx git-refs lists git refs
(branches and tags), while ccx refs finds where a symbol is used.
When a query returns nothing useful
No results./No matches.— the query or pattern found nothing. Forsearch, rephrase conceptually or raise-k/--offset. Forgrep, follow the loosening ladder above — a wrong structural guess returns empty, so re-check references/grep-syntax.md gotchas before concluding the code isn't there. Also check stderr: a CWD-subtree note means you searched only part of the repo (--path '*'widens).No definitions./No references.— first read the stderr coverage note (index not built / ref skipped / partial parse / snapshot lag → absence proves nothing); then check the language is covered (Python, TS/JS/TSX, C/C++, C#, Rust). Adefsmiss on a dotted name usually means--qualified-namewas needed (or vice versa — drop it to match the base name); arefsmiss on an exact target may be a staleentity_id— re-runccx defs.- Unknown/unindexed ref — the error lists the indexed refs; pick one or drop
--git-refto use the default.
Server/transport failures — HTTP 503 "index not built yet", HTTP 401 auth, an
expired login (the error says Run ccx login), a connection error, or ccx not
installed — are the user's environment, not something
to work around: never invent a server URL or token; surface the missing piece to
the user and see references/management.md for setup +
troubleshooting.
For agents & MCP
Everything is non-interactive and env-driven, so an agent or CI job can run ccx
directly: errors exit non-zero, notes go to stderr, results to stdout. The query
server also exposes an MCP endpoint (<CCX_SERVER_URL>/mcp) with the same
tools at parity (ccx ask is the ask_codebase tool) — the preferred path
for MCP-capable agents (no CLI install, no output parsing). See
references/management.md.