Context-frugal navigation into JVM dependency sources, for AI agents.
An agent working on a Gradle or Maven project that needs to know what
SomeLibraryClient.builder() actually does has bad options: read a
decompiled 3,000-line file whole (context gone), grep the binary jar
(noise), or guess from a doc page (hallucination risk). jarpeek gives the
agent the same navigation a human has in an IDE — find the class, see its
members, read exactly the method body asked for — at a fraction of the
token cost.
- Lazy, no index. The first query on a fresh project (or after the build files change) runs one bounded Gradle/Maven resolve and writes a manifest of the on-disk jars. That is all the up-front work there is; every later query reads the manifest and opens only the jar entries it needs.
- Shared across checkouts. The manifest lives in a machine-wide cache, keyed by the build files' contents: a git worktree or clone with the same build files answers its first query from the cache, with no Gradle/Maven run, and nothing is ever written into a checkout.
- One-file parses. A class lookup parses just the winning artifact's entry for that class — one source entry or class file, never the jar around it.
- Skeleton outlines.
outlinerenders a Java-shaped skeleton — package, imports, javadoc, members as code lines — with--minimal/--fullpresets and per-section toggles;read-sourceserves the whole file,read-memberreturns only the requested method spans. - Provenance on everything. Every answer says whether it is
source,decompiled, orsignature— so the agent knows what it is reading. - Misses are answers. Unknown classes return suggestions and searched artifacts with exit code 0, never a stack trace.
- Quiet by contract. stderr is capped at three lines per invocation; stdout stays parseable.
jarpeek resolves through the project's own build (Gradle, then Maven, then local machine caches as an explicit last resort), appends the local JDK when it ships sources, and decompiles on demand with a bundled CFR. It speaks MCP (a stdio subprocess, for harnesses that support it) and a plain CLI (for everything else).
The design rationale — why lazy instead of an index, the resolve cascade, the degradation rules — is in the design notes.
npm install -g jarpeek
jarpeek init
Run init once per machine, not per project. It wires the MCP server
or CLI hints into your AI harnesses (Claude Code, Codex, Gemini CLI, Qwen
Code, GigaCode) at user level, and stops there — no first index, no
resolve. From then on every repository, git worktree, monorepo and folder
of repos works with no further setup: jarpeek finds the builds of whatever
directory the agent runs in (see Workspaces), and the first
query auto-resolves. --yes takes the non-interactive defaults (Claude
Code + MCP).
| Harness | MCP (machine-wide) | CLI hints (machine-wide) |
|---|---|---|
| Claude Code | claude mcp add --scope user (printed for you to run when claude is not on PATH) |
~/.claude/CLAUDE.md + SessionStart hook in ~/.claude/settings.json |
| Codex | ~/.codex/config.toml |
~/.codex/AGENTS.md + hook in ~/.codex/config.toml |
| Gemini CLI, Qwen Code, GigaCode | ~/.gemini/settings.json, ~/.qwen/…, ~/.gigacode/… |
~/.<harness>/<HARNESS>.md + hook in its settings.json |
The session hook stays silent in folders with no Gradle/Maven build, so
machine-wide wiring costs nothing in other projects. init --scope project writes the configs into the current repository instead (.mcp.json,
CLAUDE.md, …) for teams that commit them; a project entry takes
precedence over the machine-wide one in that repo.
For a quick trial without installing, npx jarpeek@latest init also works
— but the wired configs invoke jarpeek by name, so install it globally
before relying on them (init warns when it is not on PATH).
Requirements: Node.js >= 20.12.0. A JVM on PATH (or JAVA_HOME) is
optional — without it, decompilation degrades to signature-only answers.
jarpeek mcp serves a stdio MCP server; init registers it for you
(machine-wide; the server serves the client's session folder). The
nine tools:
| Tool | Arguments | Answers with |
|---|---|---|
find_class |
query, limit? |
Matching classes by FQN, suffix, simple, or fuzzy name |
outline |
fqn, kind?, visibility?, preset?, sections? |
The class skeleton's declaration rows — the frugal first look |
read_member |
fqn, selectors[] |
Source slices for the named members |
read_source |
fqn, mode? (full|lines|outline), from?, to? |
Source text for one class — full by default; prefer outline |
read_resource |
artifact, glob |
Non-class jar entries (config, services, manifests) |
search_symbols |
query, artifact (required), limit?, kind? |
Declarations by member name in one artifact |
resolve |
forceUpdate? |
Forced re-resolve; one summary line (count, duration, warnings), plus the warnings when any. forceUpdate (Maven -U / Gradle --refresh-dependencies) clears cached negative lookups |
status |
— | Manifest freshness (present, path, resolvedAt, stale, incomplete, artifactCount) and JVM report |
where |
coordinates |
The artifact's recorded on-disk paths, each flagged exists or missing |
Artifact arguments take full g:a:v coordinates or a unique artifact id
(the a segment). Every tool also takes an optional project — see
Workspaces.
There is no per-project setup. jarpeek works out which Gradle/Maven builds the session means from its working directory (for MCP: the client's roots, i.e. the session folder):
- Inside a build — a service folder, a source dir, a worktree of a
single-build repo — it uses the enclosing build root (Gradle: nearest
settings.gradle[.kts]; Maven: nearest.mvn/, else the outermost parent pom), never climbing past the repository root. - Above builds — a monorepo root, a folder of repos — it uses every
build below (
git ls-filesinside a repository, so ignored and fixture projects are skipped; nested repositories included).
With several builds, queries span all of them: each build resolves
lazily (two at a time, one stderr line for the batch) and is cached like
any other manifest; find_class hits, and the served class and
alternatives of outline/read_member/read_source, carry projects
— the builds whose dependency set holds that artifact. When two builds
pin different versions, pass project (a path inside the build, or its
workspace-relative path as status lists it) to read the one you mean.
status and resolve report per build. A directory with no build at all
behaves as before: it is the project root.
Nothing needs to be primed by hand. The first query on a fresh project —
or after the build files change or a recorded jar vanishes —
auto-resolves: one bounded Gradle/Maven pass, one stderr notice line
(plus a 30s heartbeat while it runs), and the manifest is rewritten.
Resolves run the system mvn/gradle from PATH first, the project's
root wrapper as fallback — including a retry after a failed system run —
so a CI-only wrapper never blocks local use (see --build-tool).
A resolve that fails never fails the query: with a manifest on disk it is
served flagged stale with a warning; without one the query answers as a
miss, and re-resolution backs off for 60 seconds so a broken build is not
re-run per query. jarpeek resolve re-runs the cascade on demand and
reports the failure. A Maven reactor where only some modules resolve keeps
what resolved but the manifest is flagged incomplete (with the failed
modules and the Maven error) — visible in status, and a find_class
negative over it carries incomplete: true, so it never reads as a
definitive "not in any dependency". When the Maven error names a cached
negative lookup ("was cached in the local repository") — the one failure a
plain re-resolve cannot clear — the warning says so and names the fix:
jarpeek resolve -U re-runs Maven with update checks forced (-U; Gradle
resolutions get --refresh-dependencies) and writes a complete manifest in
one command; the same option reaches MCP as resolve's forceUpdate
input. The full cascade and degradation rules are in the
design notes.
Every tool is also a dash-named subcommand; --json prints the exact MCP
result object, --project <dir> picks the workspace directory (default:
cwd) — the build enclosing it, or every build below it.
Member selectors are #name or #name(Type,...) for overloads.
jarpeek --json find-class StringJoiner --limit 5
jarpeek outline java.util.StringJoiner --kind method
jarpeek outline java.util.StringJoiner --minimal
jarpeek read-member com.example.lib.ApiClient '#execute(Request,int)'
jarpeek read-source com.example.lib.ApiClient --lines 40:80
jarpeek read-resource com.example:demo-lib:1.0.0 'META-INF/**'
jarpeek search-symbols builder --artifact com.example:demo-lib:1.0.0 --kind method
jarpeek resolve
jarpeek resolve -U
jarpeek status
jarpeek where com.example:demo-lib:1.0.0
search-symbols without --artifact is a usage error.
Also: jarpeek init (wire harnesses, once per machine; --scope project
for one repo), jarpeek prime (print the agent cheatsheet; --full,
--mcp, --export, --hook-json), jarpeek mcp (serve MCP stdio).
Diagnostics — the one bootstrap notice, resolve heartbeats, warnings, degradations — go to stderr (warnings under a three-line cap); stdout stays parseable. Unknown classes exit 0 with suggestions.
Every answer carries one of three provenance values, computed for that answer rather than stored anywhere:
source— the artifact ships a sources jar (or module source dir). The real published code — and the only provenance whose outlines carry imports and javadoc (class files have neither).decompiled— no sources jar; bytecode decompiled with the bundled CFR on the local JVM. Faithful in structure, but local names, generics, and control flow are the compiler's reconstruction. Treat fine detail accordingly.signature— no sources and no decompilation (JVM unavailable, JDK classes where decompilation is out of scope, or a failed decompile): declarations only.read-memberanswers "signature only" instead of code.
find_class hits carry the provenance as a promise — what reading that
class fully would yield — computed per hit from the artifact's backings
and JVM availability.
Degradations are reported, never hidden, and the answer still arrives:
each is a warning: ... line on stderr (a degraded[] field in JSON),
aggregated under the three-line warning budget. Search results are scoped
to the resolved dependency set of the builds in scope, and
search_symbols further to the one named artifact.
| Knob | Meaning |
|---|---|
--build-tool <auto|system|wrapper> |
CLI global flag (all subcommands incl. mcp): which mvn/gradle runs resolves (auto = system first, wrapper fallback) |
resolve -U / --force-update |
Per-invocation resolve flag: force dependency update checks (Maven -U, Gradle --refresh-dependencies); the healing path for a cached-negative-lookup partial. No env mirror — it is an action, not configuration, and it does not affect the manifest fingerprint |
JARPEEK_BUILD_TOOL |
Same tri-state via environment; beats config, loses to the flag — the layer to use for harness-spawned MCP servers without server args |
--lang <en|ru> |
CLI global flag: interface language for human-mode output (help, tables' framing, warnings, errors). Flag beats the language config field; default English. --json, the MCP server, and prime output stay English — they are agent/machine contract |
JARPEEK_M2_DIR / M2_REPO |
Where the Maven local repository lives; steers both the Maven resolver's anchor and the cache scan |
JARPEEK_GRADLE_CACHE_DIR / GRADLE_USER_HOME |
Where the Gradle modules-2 cache lives (GRADLE_USER_HOME is the cache's parent — the scan walks <it>/caches/modules-2/files-2.1) |
JARPEEK_CACHE_DIR |
Where the shared manifest cache lives (default: ~/Library/Caches/jarpeek on macOS, %LOCALAPPDATA%\jarpeek on Windows, $XDG_CACHE_HOME/jarpeek or ~/.cache/jarpeek elsewhere) |
JARPEEK_HOME |
Override the home directory used for user-scoped harness configs, the global config below and the default cache dir; while set, init prints the claude mcp add command instead of running it (Claude Code's own config cannot be relocated with it) |
JARPEEK_PRIME_MODE |
Default prime mode (cli or mcp) when config is absent |
.jarpeek/PRIME.md |
Replaces the agent cheatsheet verbatim, every mode |
.jarpeek/config.json |
Per-project defaults, optional: buildTool and language ("en"/"ru") are hand-added; init --scope project records primeMode and its advanced step's m2Dir/gradleCacheDir pins here |
~/.config/jarpeek/config.json |
Machine-wide defaults: primeMode and the m2Dir / gradleCacheDir pins written by init (or by hand), read when the project config names none |
Cache roots converge in a fixed order — explicit env var, project config,
global config, maven's settings.xml <localRepository> (m2 only), then
the default ~/.m2/repository — and env beats config, like buildTool:
both are hand-authored machine facts a one-off shell override should win
against. init shows the detected roots and persists only an explicit
override. Nothing to configure still works: when no candidate matches
mvn's classpath output, the resolver derives the repository root from the
output itself (quorum-guaranteed) and says so with a
maven: m2-anchor-derived:<path> warning; jarpeek status prints the
effective roots and the layer each came from.
Flipping the build-tool setting or the m2 root re-resolves: both are part of the manifest fingerprint, so a manifest produced by the other tool — or anchored at another repository — is never served as fresh.
The manifest is the only derived state jarpeek writes to disk, and it
lives in the cache directory above, never in the project: entries are
keyed by a hash of the build files' contents (plus the build-tool
strategy and m2 root), shared by every checkout with the same build
files, rebased onto each checkout's own module paths, and pruned after
30 days unused. Concurrent resolves of the same build files — parallel
agents in sibling worktrees — run the build once; the others wait for
its manifest. A project's .jarpeek/ holds only hand-authored
configuration (config.json, PRIME.md); earlier versions'
.jarpeek/manifest.json and .jarpeek/gradle-init.gradle are unused and
safe to delete (see the design notes).
- No type hierarchy, find-usages, or call-graph navigation — declaration lookup and source reads only.
- Member-name search is scoped to one artifact (
search_symbolsrequires itsartifactargument). - JDK classes need
$JAVA_HOME/lib/src.zip— there is no jimage fallback. Without it, JDK classes are unavailable. - Source-jar FQNs are derived from entry paths: a jar whose entries mismatch its declared packages can misreport a class's location.
- No generated-source awareness (protobuf, Dagger): generated classes appear only if they land in the resolved artifacts.
- No remote artifact search (Maven Central by coordinates): on a miss, jarpeek reports what it searched and stops rather than fetching.
- Maven SNAPSHOT dependencies resolved to timestamped jars
(
artifact-1.0-20240501.123456-3.jar) are not recognized by the Maven resolver's m2-layout matching and are skipped. - Decompilation quality is CFR's;
signatureis the floor when no JVM is available. - Gradle composite builds (
includeBuild) and nested independent builds inside a build count as part of the enclosing build: only its own projects' dependencies are resolved.
npm run build # tsc → dist/
npm test # vitest run (unit + integration, offline)
npm run typecheck # tsc --noEmit
node scripts/build-fixtures.mjs # rebuild test fixture jars (deterministic)
Network-dependent e2e tests self-skip unless JARPEEK_E2E=1 is set. Never
edit generated fixture jars by hand — edit test/fixtures/src and rerun
the build script.
CI runs on pushes to main and on PRs; releases are tag-driven. To cut
one:
npm version minor # bump commit + v* tag (patch | minor | major)
git push --follow-tags # the release workflow takes over
The workflow verifies the tag matches package.json, reruns every gate,
publishes to npm with provenance via trusted publishing — no tokens
stored in the repo — and opens a GitHub Release with notes grouped from
conventional commits.
One-time setup: on npmjs.com, list this repository and release.yml as a
trusted publisher for the jarpeek package.
Decompilation ships CFR 0.152 by Lee
Benfield (MIT); its license is included as vendor/cfr-LICENSE.txt.
MIT — see LICENSE.