gqls
Fuzzy and semantic search over a GraphQL schema — from the terminal, for very large graphs.
Point gqls at a schema and find the type, field, argument, or directive you're after — by approximate name, by meaning, or by jumping straight to its resolver in code. It reads an SDL file, an introspection dump, a live endpoint, or a federated supergraph, so instead of grepping SDL and guessing the exact spelling you get ranked matches — even on schemas too big to scroll, where GitHub's ~68k-line API answers in ~0.15s.
Why
Nothing else combines fuzzy/semantic search with big-schema speed in a CLI. Schema viewers list and filter but don't fuzzy-match, hosted explorers are GUIs, and the semantic-search tools are built for agents, not developers. gqls fills that gap and stays Unix-composable — -j/-J emit JSON/NDJSON everywhere.
Install
# Homebrew (fuzzy + introspection + resolver jump + semantic search)
# Cargo (crate is `gqls-cli`; installs the `gqls` binary, semantic search included)
# Lean, fuzzy-only build (no ONNX Runtime download)
The resolver jump (-R) shells out to rq; install it too if you want that.
Usage
Input sources
- SDL file —
gqls user schema.graphql - Introspection JSON dump —
gqls user schema.json - Live endpoint —
gqls user https://api.example.com/graphql(POSTs the introspection query; add auth with-H "Authorization: Bearer …", repeatable). Remote responses are cached ~1h so repeat queries don't refetch all day; alocalhostendpoint is never cached (you're likely editing that schema). Tune withGQLS_INTROSPECT_TTL(seconds;0disables),--refreshto bypass,--clear-cacheto wipe. - Auto-discovery — omit the source and
gqlsfinds a schema in the current tree (preferring.graphqls, thenschema.*, then an introspection.json, then any SDL-looking.graphql; in a federated monorepo, asupergraph*schema wins when several exist).
Federated schemas (Apollo Federation v2)
gqls parses subgraph SDL directly — the extend schema @link(...) header and @key/@shareable directives that trip up plain GraphQL parsers — so you can cd into a subgraph package and search its own schema. Auto-discovery follows suit: at the repo root it prefers the composed supergraph* schema, but run from inside a subgraph it uses that subgraph's local schema.
Fuzzy search (default)
Handles abbreviations (usr → User), typos and transpositions (usre → User), and qualified Type.field queries. Results rank by match quality, with root Query/Mutation fields floated up. Weak long-tail matches are cut relative to the best hit; -v reports the total match count when it exceeds the limit.
Filter by return type
--returns TYPE keeps only fields whose type is TYPE, ignoring []/! wrappers — the way to find a field when you know what it returns but not what it's called:
A name search can't answer this: Query.myEmployer: Company doesn't contain the word "Company" anywhere in its name or path. With no QUERY at all, --returns lists everything it matches.
Wildcards
A wildcard in the query switches from fuzzy search to enumeration — every match is exact, ordered by kind then alphabetically. Quote the pattern so your shell doesn't expand it against local filenames:
A trailing . is shorthand for .*, which is the form worth remembering: no shell quoting required. Beyond that, three metacharacters and nothing else: * (any run of characters), ? (exactly one), and {a,b} (alternatives, nestable). * and ? span ., so 'User.*' reaches nested paths. Patterns are anchored, so 'User.*' never wanders into UserProfile. There's no escape syntax — GraphQL names can't contain these characters anyway — and a query with whitespace is treated as prose, so a phrase ending in ? stays a normal search.
Wildcards skip semantic ranking (you asked for a list, not a guess); combine them with -k to narrow further (gqls '*.email' -k input_field).
In a qualified query, a Type that names a schema type (any case) becomes a hard filter — Company.employe searches only Company's members, not every type starting with "Company". A misspelled qualifier snaps to the unique closest type (Compnay.employe → Company, announced on stderr); one that matches nothing falls back to plain fuzzy matching. That correction applies to fuzzy queries, not to wildcards — patterns match literally, so Compnay. finds nothing rather than guessing.
Semantic search — automatic, combined with fuzzy
By default gqls returns fuzzy matches and semantic ones, merged via Reciprocal Rank Fusion, so exact-name and meaning-based hits both surface (fuzzy weighted a touch higher to keep exact matches on top). Semantic ranking uses a local all-MiniLM-L6-v2 model (ONNX Runtime), MRL-compressed to 64 dimensions and cosine-ranked; the model is fetched once from the HuggingFace Hub, then cached offline.
When the query names something that exists — an exact match, or the word whole at a boundary (name → lastName) — the combine is skipped: fuzzy found what you typed, so meaning-based lookalikes would only pad the list. --semantic forces it back on. The space form (gqls 'User name') is the loose variant: same type filter, but semantic stays on, so nearby fields (lastName, firstName) surface too. Semantic results are tail-bounded relative to the best hit, so a large -l can't fill with monotonic noise.
Per-record vectors are cached, keyed by schema content and model. The first time gqls sees a schema it returns fuzzy results immediately and embeds in the background — so the next run is combined and instant (GitHub's schema: ~40s to embed once, then ~0.3s warm queries). GQLS_NO_AUTOWARM=1 disables the background embed. Editing the schema re-embeds on its own; --refresh forces a re-embed, --clear-cache wipes the cache, and gqls --warm <schema> embeds up front (e.g. in CI). Semantic needs a semantic build — the default cargo install and Homebrew have it; --no-default-features is fuzzy-only.
Draft an example operation (-e)
Find a field, then get something you can paste into a client:
) {
) {
# employee: Employee — add fields you need
}
}
# optional arguments, omitted above:
# updateEmployee(dryRun: Boolean = false)
# input types:
# EmployeeInput {
# name: String!
# role: Role
# }
# Role = ADMIN | MEMBER | GUEST
# variables
{
}
The rules are deliberately conservative, because a wrong guess costs more than a visible hole:
- Arguments you must supply become variables — nothing is inlined into the query body, and each placeholder names its type (
"<ID!>"), so it can't be mistaken for a usable value the way""or0can. - Anything the server can supply is left out and listed underneath — a nullable argument, or one with a schema default (even a non-null one, like
first: Int! = 10). The operation runs as-is, and the knobs you skipped are still visible with their defaults. - One level of selection, leaf fields only. A scalar or enum return gets no selection set at all. An object return gets its scalar/enum fields plus a
# add fields you needmarker per object-valued field. - An
errorsblock only if the schema really has one — the payload/errors convention is common, not universal, so it's expanded only when that field exists. - Input objects and enums are expanded underneath — every input type the arguments refer to, followed transitively through input fields, so you can fill in
"<EmployeeInput!>"without opening the schema. Expansion is flat and each type appears once, so a self-referential filter (Filter { and: [Filter!] }) terminates. - A nested field is wrapped in a root that returns its type.
gqls Company.employee -efinds a root returningCompany(preferring one with fewest required arguments) and nests through it. When several roots qualify, the pick and the alternatives are both named on stderr. When none does, it's an error with a pointer to--returns, not a guess.
A union return can't be selected without inline fragments, so it gets __typename and a marker. Every drafted operation is parsed back with a GraphQL parser in the test suite, and a network-gated test drafts against a live endpoint and executes the result there — so what it prints is not just well-formed but accepted by the server it came from. -j/--json emits {path, operation, variables, optional_args, input_types} for scripting.
Resolver jump (-R, graphql-ruby)
Find a field, then jump to the resolver or method that implements it, via rq:
)
gqls tries graphql-ruby naming conventions (resolver class, type method, mutation class) and ranks the candidates. When the schema is a local file, candidates are also ranked by package proximity to it — so in a federated monorepo the resolver in the schema's own subgraph wins over a same-named one elsewhere.
Output
Text results carry the path, the type, the kind, and the schema description — a match is usually confirmable without opening the schema:
)
Long descriptions are elided to one line; -D/--no-description drops them entirely. Every mode also supports -j/--json (pretty array) and -J/--ndjson (one record per line), which always carry the full description text. Status chatter goes to stderr, so JSON pipes clean:
|
-q/--quiet silences the stderr status lines (results and hard errors still print); -v/--verbose adds diagnostics — cache hits/misses, the rq candidates -R tried, and why the embedding model loaded or fell back to the hash embedder. Under -R, verbose also passes -v through to rq and streams its trace.
Shell completions: gqls --completions zsh (or bash/fish/…).
Using with Claude Code
gqls ships with a Claude Code skill (claude/gqls-skill.md) so Claude reaches for it when navigating a GraphQL schema instead of grepping SDL by hand. Install it:
Development
script/check.sh is the gate — formatting, clippy, and tests across every feature configuration gqls ships (default/semantic, fuzzy-only, and the semantic-dynamic build Homebrew uses). Run it before pushing.
How it works
Layered so the core is one idea — flatten every schema entity to a searchable record, and let search and output touch nothing but records:
src/
model.rs SchemaRecord + Kind (the only shared vocabulary)
load/ SDL parse · introspection (URL/JSON) · schema discovery
search/ the fuzzy scorer (a DP subsequence aligner + typo tier)
semantic/ embedding search + on-disk vector cache (feature = "semantic")
resolve.rs field -> resolver jump (shells out to rq)
cli.rs clap + unified text/json/ndjson output
Two capabilities are borrowed from sibling tools rather than reinvented: the fuzzy ranking is ported from rq's aligner, and the local embedding pipeline is copied from ae.
License
MIT — see LICENSE.