Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@ jobs:
java-version: '17'
distribution: 'temurin'

- name: Install scala-cli and sbt
run: |
curl -fL "https://github.com/coursier/coursier/releases/latest/download/cs-x86_64-pc-linux.gz" | gzip -d > /usr/local/bin/cs
chmod +x /usr/local/bin/cs
cs install scala-cli sbt
echo "$HOME/.local/share/coursier/bin" >> "$GITHUB_PATH"

- run: ./mill _.test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ langoustine-tracer
.vscode
.claude
ai
.cellar
28 changes: 23 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,29 @@

1. Cellar

When you need the API of a JVM dependency, always use cellar. Use it before metals-mcp. Usage:
cellar get <coordinate> <fqn> # single symbol
cellar list <coordinate> <package> # explore a package
cellar search <coordinate> <query> # find by name
cellar deps <coordinate> # dependency tree
When you need the API of a JVM dependency, always use cellar. Use it before metals-mcp.

### Project-aware commands (run from project root)

For querying the current project's code and dependencies (auto-detects build tool):

cellar get [--module <name>] <fqn> # single symbol
cellar list [--module <name>] <package> # explore a package
cellar search [--module <name>] <query> # find by name

- Mill/sbt projects: `--module` is required (e.g. `--module lib`, `--module core`)
- scala-cli projects: `--module` is not supported (omit it)
- `--no-cache`: skip classpath cache, re-extract from build tool
- `--java-home`: override JRE classpath

### External commands (query arbitrary Maven coordinates)

For querying any published artifact by explicit coordinate:

cellar get-external <coordinate> <fqn> # single symbol
cellar list-external <coordinate> <package> # explore a package
cellar search-external <coordinate> <query> # find by name
cellar deps <coordinate> # dependency tree

Coordinates must be explicit: group:artifact_3:version

Expand Down
2 changes: 2 additions & 0 deletions build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ object lib extends CommonScalaModule {
fixtureJava.publishM2LocalCached()
fixtureScala2.publishM2LocalCached()
fixtureScala3.publishM2LocalCached()
val millAbsPath = (lib.moduleDir / os.up / "mill").toString
Seq(
s"-Dcellar.test.millBinary=$millAbsPath",
s"-Dcellar.test.localM2=${os.home / ".m2" / "repository"}",
s"-Dcellar.test.fixtureJavaGroup=cellar.test",
s"-Dcellar.test.fixtureJavaArtifact=cellar-fixture-java",
Expand Down
114 changes: 70 additions & 44 deletions cli/src/cellar/cli/CellarApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package cellar.cli
import cats.effect.{ExitCode, IO}
import cats.syntax.all.*
import cellar.*
import cellar.handlers.{DepsHandler, GetHandler, GetSourceHandler, ListHandler, SearchHandler}
import cellar.handlers.{DepsHandler, GetHandler, GetSourceHandler, ListHandler, ProjectGetHandler, ProjectListHandler, ProjectSearchHandler, SearchHandler}
import com.monovore.decline.*
import com.monovore.decline.effect.*
import coursierapi.{MavenRepository, Repository}
Expand All @@ -16,19 +16,22 @@ object CellarApp
version = BuildInfo.version
):

private val javaHomeOpt: Opts[Option[Path]] =
Opts.option[Path]("java-home", "Use a specific JDK for JRE classpath").orNone

override def main: Opts[IO[ExitCode]] =
(javaHomeOpt, getSubcmd orElse getSourceSubcmd orElse listSubcmd orElse searchSubcmd orElse depsSubcmd)
.mapN((javaHome, f) => f(javaHome))
getSubcmd orElse getExternalSubcmd orElse
getSourceSubcmd orElse
listSubcmd orElse listExternalSubcmd orElse
searchSubcmd orElse searchExternalSubcmd orElse
depsSubcmd

private val coordArg: Opts[String] =
Opts.argument[String]("coordinate")

private val symbolArg: Opts[String] =
Opts.argument[String]("fully-qualified-symbol")

private val javaHomeOpt: Opts[Option[Path]] =
Opts.option[Path]("java-home", "Use a specific JDK for JRE classpath").orNone

private val extraReposOpt: Opts[List[Repository]] =
Opts.options[String]("repository", "Extra Maven repository URL (repeatable)", short = "r", metavar = "url")
.orEmpty
Expand All @@ -39,63 +42,86 @@ object CellarApp
.option[Int]("limit", "Maximum number of results to return", short = "l", metavar = "N")
.withDefault(50)

private val moduleOpt: Opts[Option[String]] =
Opts.option[String]("module", "Build module name (required for Mill/sbt)", short = "m", metavar = "name").orNone

private val noCacheOpt: Opts[Boolean] =
Opts.flag("no-cache", "Skip classpath cache (re-extract from build tool)").orFalse

private def parseAndResolve(raw: String, extraRepos: List[Repository]): IO[Either[String, MavenCoordinate]] =
MavenCoordinate.parse(raw) match
case Left(err) => IO.pure(Left(err))
case Right(coord) => coord.resolveLatest(extraRepos).map(Right(_))

private val getSubcmd: Opts[Option[Path] => IO[ExitCode]] =
Opts.subcommand("get", "Fetch all information about a named symbol") {
(coordArg, symbolArg, extraReposOpt).mapN { (rawCoord, fqn, extraRepos) =>
(javaHome: Option[Path]) =>
parseAndResolve(rawCoord, extraRepos).flatMap {
case Left(err) => IO.blocking(System.err.println(err)).as(ExitCode.Error)
case Right(coord) => GetHandler.run(coord, fqn, javaHome, extraRepos)
}
private val getSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("get", "Fetch symbol info from the current project") {
(symbolArg, moduleOpt, javaHomeOpt, noCacheOpt).mapN { (fqn, module, javaHome, noCache) =>
ProjectGetHandler.run(fqn, module, javaHome, noCache)
}
}

private val getSourceSubcmd: Opts[Option[Path] => IO[ExitCode]] =
Opts.subcommand("get-source", "Fetch the source code of a named symbol") {
(coordArg, symbolArg, extraReposOpt).mapN { (rawCoord, fqn, extraRepos) =>
(javaHome: Option[Path]) =>
parseAndResolve(rawCoord, extraRepos).flatMap {
case Left(err) => IO.blocking(System.err.println(err)).as(ExitCode.Error)
case Right(coord) => GetSourceHandler.run(coord, fqn, javaHome, extraRepos)
}
private val listSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("list", "List symbols in a package or class from the current project") {
(symbolArg, moduleOpt, limitOpt, javaHomeOpt, noCacheOpt).mapN { (fqn, module, limit, javaHome, noCache) =>
ProjectListHandler.run(fqn, module, limit, javaHome, noCache)
}
}

private val listSubcmd: Opts[Option[Path] => IO[ExitCode]] =
Opts.subcommand("list", "List symbols in a package or class") {
(coordArg, symbolArg, limitOpt, extraReposOpt).mapN { (rawCoord, fqn, limit, extraRepos) =>
(javaHome: Option[Path]) =>
parseAndResolve(rawCoord, extraRepos).flatMap {
case Left(err) => IO.blocking(System.err.println(err)).as(ExitCode.Error)
case Right(coord) => ListHandler.run(coord, fqn, limit, javaHome, extraRepos)
}
private val searchSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("search", "Substring search for symbol names in the current project") {
(Opts.argument[String]("query"), moduleOpt, limitOpt, javaHomeOpt, noCacheOpt).mapN {
(query, module, limit, javaHome, noCache) =>
ProjectSearchHandler.run(query, module, limit, javaHome, noCache)
}
}
Comment on lines +56 to 76
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI semantics have changed so get/list/search now operate on the current project and the old coordinate-based behavior moved to *-external. README.md still documents cellar get <coordinate> <fqn> etc., which will now be incorrect for users. Please update the README (and any other user docs) to match the new command names/meaning.

Copilot uses AI. Check for mistakes.

private val searchSubcmd: Opts[Option[Path] => IO[ExitCode]] =
Opts.subcommand("search", "Substring search for symbol names") {
(coordArg, Opts.argument[String]("query"), limitOpt, extraReposOpt).mapN {
(rawCoord, query, limit, extraRepos) =>
(javaHome: Option[Path]) =>
parseAndResolve(rawCoord, extraRepos).flatMap {
case Left(err) => IO.blocking(System.err.println(err)).as(ExitCode.Error)
case Right(coord) => SearchHandler.run(coord, query, limit, javaHome, extraRepos)
}
private val getExternalSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("get-external", "Fetch symbol info from a Maven coordinate") {
(coordArg, symbolArg, javaHomeOpt, extraReposOpt).mapN { (rawCoord, fqn, javaHome, extraRepos) =>
parseAndResolve(rawCoord, extraRepos).flatMap {
case Left(err) => IO.blocking(System.err.println(err)).as(ExitCode.Error)
case Right(coord) => GetHandler.run(coord, fqn, javaHome, extraRepos)
}
}
}

private val depsSubcmd: Opts[Option[Path] => IO[ExitCode]] =
Opts.subcommand("deps", "Print the transitive dependency list") {
(coordArg, extraReposOpt).mapN { (rawCoord, extraRepos) =>
(_: Option[Path]) =>
private val getSourceSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("get-source", "Fetch the source code of a named symbol") {
(coordArg, symbolArg, javaHomeOpt, extraReposOpt).mapN { (rawCoord, fqn, javaHome, extraRepos) =>
parseAndResolve(rawCoord, extraRepos).flatMap {
case Left(err) => IO.blocking(System.err.println(err)).as(ExitCode.Error)
case Right(coord) => GetSourceHandler.run(coord, fqn, javaHome, extraRepos)
}
}
}

private val listExternalSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("list-external", "List symbols from a Maven coordinate") {
(coordArg, symbolArg, limitOpt, javaHomeOpt, extraReposOpt).mapN { (rawCoord, fqn, limit, javaHome, extraRepos) =>
parseAndResolve(rawCoord, extraRepos).flatMap {
case Left(err) => IO.blocking(System.err.println(err)).as(ExitCode.Error)
case Right(coord) => ListHandler.run(coord, fqn, limit, javaHome, extraRepos)
}
}
}

private val searchExternalSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("search-external", "Substring search for symbol names from a Maven coordinate") {
(coordArg, Opts.argument[String]("query"), limitOpt, javaHomeOpt, extraReposOpt).mapN {
(rawCoord, query, limit, javaHome, extraRepos) =>
parseAndResolve(rawCoord, extraRepos).flatMap {
case Left(err) => IO.blocking(System.err.println(err)).as(ExitCode.Error)
case Right(coord) => DepsHandler.run(coord, extraRepositories = extraRepos)
case Right(coord) => SearchHandler.run(coord, query, limit, javaHome, extraRepos)
}
}
}

private val depsSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("deps", "Print the transitive dependency list") {
(coordArg, extraReposOpt).mapN { (rawCoord, extraRepos) =>
parseAndResolve(rawCoord, extraRepos).flatMap {
case Left(err) => IO.blocking(System.err.println(err)).as(ExitCode.Error)
case Right(coord) => DepsHandler.run(coord, extraRepositories = extraRepos)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
[
{
"name": "jdk.internal.jrtfs.JrtFileSystemProvider",
"allDeclaredConstructors": true,
"allDeclaredMethods": true
},
{
"name": "com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl",
"allDeclaredConstructors": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
"includes": [
{"pattern": "\\Qcoursier/coursier.properties\\E"},
{"pattern": "\\Qcoursier/launcher/coursier.properties\\E"},
{"pattern": "\\Qlibrary.properties\\E"},
{"pattern": "\\Qjre.bin\\E"}
{"pattern": "\\Qlibrary.properties\\E"}
]
}
}
31 changes: 29 additions & 2 deletions lib/src/cellar/CellarError.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package cellar

import cellar.build.BuildToolKind
import java.nio.file.Path

sealed trait CellarError extends Throwable
Expand Down Expand Up @@ -28,10 +29,11 @@ object CellarError:
if nearMatches.isEmpty then base
else s"$base Did you mean one of: ${nearMatches.mkString(", ")}?"

final case class PartialResolution(fqn: String, coord: MavenCoordinate, resolvedFqn: String, missingMember: String)
final case class PartialResolution(fqn: String, coord: Option[MavenCoordinate], resolvedFqn: String, missingMember: String)
extends CellarError:
override def getMessage: String =
s"Symbol '$fqn' not found in '${coord.render}'. Resolved up to '$resolvedFqn' but member '$missingMember' was not found."
val context = coord.fold("")(c => s" in '${c.render}'")
s"Symbol '$fqn' not found$context. Resolved up to '$resolvedFqn' but member '$missingMember' was not found."

final case class PackageGivenToGet(fqn: String) extends CellarError:
override def getMessage: String =
Expand All @@ -45,3 +47,28 @@ object CellarError:
extends CellarError:
override def getMessage: String =
s"Symbol '$fqn' exists in multiple JARs on the classpath: '$firstJar' and '$duplicateJar'."

final case class ModuleRequired(tool: BuildToolKind) extends CellarError:
override def getMessage: String =
s"--module is required for ${toolName(tool)} projects."

final case class ModuleNotSupported(tool: BuildToolKind) extends CellarError:
override def getMessage: String =
s"--module is not supported for ${toolName(tool)} projects."

final case class CompilationFailed(tool: BuildToolKind, stderr: String) extends CellarError:
override def getMessage: String =
s"Compilation failed:\n$stderr"

final case class ClasspathExtractionFailed(tool: BuildToolKind, details: String) extends CellarError:
override def getMessage: String =
s"Failed to extract classpath from ${toolName(tool)}: $details"

final case class ModuleNotFound(tool: BuildToolKind, module: String) extends CellarError:
override def getMessage: String =
s"Module '$module' not found."

private def toolName(kind: BuildToolKind): String = kind match
case BuildToolKind.Mill => "Mill"
case BuildToolKind.Sbt => "sbt"
case BuildToolKind.ScalaCli => "scala-cli"
1 change: 0 additions & 1 deletion lib/src/cellar/ContextResource.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cellar
import cats.effect.{IO, Resource}
import cats.syntax.monadError.*
import coursierapi.Repository
import tastyquery.Classpaths
import tastyquery.Classpaths.Classpath
import tastyquery.Contexts.Context
import tastyquery.jdk.ClasspathLoaders
Expand Down
7 changes: 3 additions & 4 deletions lib/src/cellar/JreClasspath.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cellar

import cats.effect.IO
import java.io.ByteArrayInputStream
import java.net.{URI, URLClassLoader}
import java.nio.file.{Files, FileSystems, Path}
import java.util.zip.ZipInputStream
Expand Down Expand Up @@ -60,14 +59,14 @@ object JreClasspath:
throw new RuntimeException(
"Bundled JRE not found. This is a build error — please report it."
)
try parseJarToClasspath(stream.readAllBytes())
try parseStreamToClasspath(stream)
finally stream.close()
}

private def parseJarToClasspath(jarBytes: Array[Byte]): Classpaths.Classpath =
private def parseStreamToClasspath(input: java.io.InputStream): Classpaths.Classpath =
val pkgMap = mutable.LinkedHashMap[String, mutable.LinkedHashMap[String, (Option[IArray[Byte]], Option[IArray[Byte]])]]()

val zis = new ZipInputStream(new ByteArrayInputStream(jarBytes))
val zis = new ZipInputStream(input)
var entry = zis.getNextEntry()
while entry != null do
val name = entry.getName
Expand Down
18 changes: 18 additions & 0 deletions lib/src/cellar/build/BuildFingerprint.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package cellar.build

import cats.effect.IO
import java.nio.file.{Files, Path}
import java.security.MessageDigest

object BuildFingerprint:
def compute(files: List[Path], module: String): IO[String] =
IO.blocking {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(module.getBytes)
files.sorted.foreach { path =>
if Files.exists(path) then
digest.update(path.toString.getBytes)
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SHA-256 fingerprint uses String.getBytes without an explicit charset. That makes the cache key depend on the platform default charset, which can change across environments. Use a fixed charset (e.g., UTF-8) for module and path.toString bytes to keep fingerprints deterministic.

Suggested change
object BuildFingerprint:
def compute(files: List[Path], module: String): IO[String] =
IO.blocking {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(module.getBytes)
files.sorted.foreach { path =>
if Files.exists(path) then
digest.update(path.toString.getBytes)
import java.nio.charset.StandardCharsets
object BuildFingerprint:
def compute(files: List[Path], module: String): IO[String] =
IO.blocking {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(module.getBytes(StandardCharsets.UTF_8))
files.sorted.foreach { path =>
if Files.exists(path) then
digest.update(path.toString.getBytes(StandardCharsets.UTF_8))

Copilot uses AI. Check for mistakes.
digest.update(Files.readAllBytes(path))
}
digest.digest().map(b => f"$b%02x").mkString
}
16 changes: 16 additions & 0 deletions lib/src/cellar/build/BuildTool.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package cellar.build

import cats.effect.IO
import cellar.CellarError
import java.nio.file.Path

trait BuildTool:
def kind: BuildToolKind
def compile(module: Option[String]): IO[Unit]
def extractClasspath(module: Option[String]): IO[List[Path]]
def fingerprintFiles(): IO[List[Path]]

protected def requireModule(module: Option[String]): IO[String] =
module match
case Some(m) => IO.pure(m)
case None => IO.raiseError(CellarError.ModuleRequired(kind))
24 changes: 24 additions & 0 deletions lib/src/cellar/build/BuildToolDetector.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package cellar.build

import cats.effect.IO
import java.nio.file.{Files, Path}

enum BuildToolKind:
case Mill, Sbt, ScalaCli

object BuildToolDetector:
private val millMarkers = List("build.mill", "build.sc", "build.mill.yaml", "build.yaml")

/** Detect the build tool kind from marker files only (no binary check). */
def detectKind(dir: Path): IO[BuildToolKind] =
IO.blocking {
val millMarker = millMarkers.find(m => Files.exists(dir.resolve(m)))
val hasSbt = Files.exists(dir.resolve("build.sbt"))
val hasScalaBuild = Files.isDirectory(dir.resolve(".scala-build"))

if millMarker.isDefined then BuildToolKind.Mill
else if hasSbt then BuildToolKind.Sbt
else if hasScalaBuild then BuildToolKind.ScalaCli
else BuildToolKind.ScalaCli // fallback
}

Loading
Loading