Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .cellar/cellar.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
starvation-checks {
enabled = true
}
Comment thread
rochala marked this conversation as resolved.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ langoustine-tracer
.vscode
.claude
ai
.cellar
.cellar/cache
result
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ Examples are testing match exhaustivity, typesystem etc.
- Use `fs2.io.file.Path` for file references, not `java.io.File` or `java.nio.file.Path`
- Coursier error handling: match `coursierapi.error.CoursierError`, call `CoordinateCompleter.suggest` to attach suggestions to `CellarError.CoordinateNotFound`

## Documentation / Development history
## Documentation

When adding or modifying CLI commands, flags, or config options, update `README.md` accordingly.

## Development history

All plans, decision and architecture designs are available in ai/ folder
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@ cellar get-external org.typelevel:cats-core_3:latest cats.Monad
|---|---|---|
| `--module <name>`, `-m` | project commands | Build module name (required for Mill/sbt) |
| `--no-cache` | project commands | Skip classpath cache, re-extract from build tool |
| `--config <path>`, `-c` | project commands | Path to config file |
| `--java-home <path>` | all | Use a specific JDK for JRE classpath |
| `-r`, `--repository <url>` | external commands | Extra Maven repository URL (repeatable) |
| `-l`, `--limit <N>` | `list`, `list-external`, `search`, `search-external` | Max results (default: 50) |
Expand All @@ -152,8 +151,6 @@ Cellar loads configuration from HOCON files and environment variables. Files are
2. `~/.cellar/cellar.conf` (user-level, optional)
3. `.cellar/cellar.conf` (project-level, optional)

Use `--config <path>` / `-c <path>` to load a specific file instead.

### Default config

```hocon
Expand All @@ -168,6 +165,12 @@ sbt {
# Extra arguments passed to sbt, space-separated (e.g. "--client")
extra-args = "" # env: CELLAR_SBT_EXTRA_ARGS
}

starvation-checks {
# Enable Cats Effect CPU starvation warnings (default: false).
# Set to true during development or CI to surface warnings.
enabled = false # env: CELLAR_STARVATION_CHECKS_ENABLED
}
Comment on lines +169 to +173
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

Because the runtime starvation-check behavior is determined from Config.bootstrap (loaded from default locations) during runtimeConfig, configs supplied via --config won’t affect whether warnings are suppressed/enabled. Either document this limitation, or provide a separate pre-runtime override mechanism (e.g., env/sysprop) that matches the README config guidance.

Copilot uses AI. Check for mistakes.
```

### Examples
Expand Down
25 changes: 15 additions & 10 deletions cli/src/cellar/cli/CellarApp.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cellar.cli

import cats.effect.{ExitCode, IO}
import cats.effect.unsafe.IORuntimeConfig
import cats.syntax.all.*
import cellar.*
import cellar.handlers.{DepsHandler, GetHandler, GetSourceHandler, ListHandler, MetaHandler, ProjectGetHandler, ProjectListHandler, ProjectSearchHandler, SearchHandler}
Expand All @@ -9,13 +10,20 @@ import com.monovore.decline.effect.*
import coursierapi.{MavenRepository, Repository}
import fs2.io.file.Path

import scala.concurrent.duration.Duration

object CellarApp
extends CommandIOApp(
name = "cellar",
header = "Inspect Maven-published JVM dependency APIs",
version = BuildInfo.version
):

override def runtimeConfig: IORuntimeConfig =
val base = super.runtimeConfig
if Config.global.starvationChecks.enabled then base
else base.copy(cpuStarvationCheckInitialDelay = Duration.Inf)
Comment thread
rochala marked this conversation as resolved.

override def main: Opts[IO[ExitCode]] =
getSubcmd orElse getExternalSubcmd orElse
getSourceSubcmd orElse
Expand Down Expand Up @@ -50,33 +58,30 @@ object CellarApp
private val noCacheOpt: Opts[Boolean] =
Opts.flag("no-cache", "Skip classpath cache (re-extract from build tool)").orFalse

private val configOpt: Opts[IO[Config]] =
Opts.option[Path]("config", "Path to config file", "c").orNone.map(Config.load)

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[IO[ExitCode]] =
Opts.subcommand("get", "Fetch symbol info from the current project") {
(symbolArg, moduleOpt, configOpt, javaHomeOpt, noCacheOpt).mapN { (fqn, module, configIO, javaHome, noCache) =>
configIO.flatMap(ProjectGetHandler.run(fqn, module, _, javaHome, noCache))
(symbolArg, moduleOpt, javaHomeOpt, noCacheOpt).mapN { (fqn, module, javaHome, noCache) =>
ProjectGetHandler.run(fqn, module, javaHome, noCache)
}
}

private val listSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("list", "List symbols in a package or class from the current project") {
(symbolArg, moduleOpt, limitOpt, configOpt, javaHomeOpt, noCacheOpt).mapN { (fqn, module, limit, configIO, javaHome, noCache) =>
configIO.flatMap(ProjectListHandler.run(fqn, module, limit, _, javaHome, noCache))
(symbolArg, moduleOpt, limitOpt, javaHomeOpt, noCacheOpt).mapN { (fqn, module, limit, javaHome, noCache) =>
ProjectListHandler.run(fqn, module, limit, javaHome, noCache)
}
}

private val searchSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("search", "Substring search for symbol names in the current project") {
(Opts.argument[String]("query"), moduleOpt, limitOpt, configOpt, javaHomeOpt, noCacheOpt).mapN {
(query, module, limit, configIO, javaHome, noCache) =>
configIO.flatMap(ProjectSearchHandler.run(query, module, limit, _, javaHome, noCache))
(Opts.argument[String]("query"), moduleOpt, limitOpt, javaHomeOpt, noCacheOpt).mapN {
(query, module, limit, javaHome, noCache) =>
ProjectSearchHandler.run(query, module, limit, javaHome, noCache)
}
}

Expand Down
11 changes: 10 additions & 1 deletion lib/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,13 @@ sbt {
# Several arguments can be passed by separating them with spaces
extra-args = ""
extra-args = ${?CELLAR_SBT_EXTRA_ARGS}
}
}

starvation-checks {
# cats-effect's CPU starvation checker emits warnings when the compute pool
# is blocked. cellar shells out to mill/sbt for classpath extraction, which
# routinely trips the default checker with noise that is not actionable for
# a short-lived CLI. Disabled by default; set to true if debugging.
enabled = false
enabled = ${?CELLAR_STARVATION_CHECKS_ENABLED}
}
33 changes: 13 additions & 20 deletions lib/src/cellar/Config.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package cellar

import cats.effect.IO
import cats.syntax.all.*
import fs2.io.file.{Files, Path}
import fs2.io.file.Path
import pureconfig.*

case class MillConfig(binary: String) derives ConfigReader
Expand All @@ -11,25 +9,20 @@ case class SbtConfig(binary: String, extraArgs: String) derives ConfigReader {
def effectiveExtraArgs: List[String] = extraArgs.split("\\s+").filter(_.nonEmpty).toList
}

case class Config(mill: MillConfig, sbt: SbtConfig) derives ConfigReader
case class StarvationChecksConfig(enabled: Boolean) derives ConfigReader

object Config {
lazy val default: IO[Config] = load(None)
case class Config(mill: MillConfig, sbt: SbtConfig, starvationChecks: StarvationChecksConfig) derives ConfigReader
Comment on lines +12 to +14
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

PR metadata describes a suppress-starvation-warnings option (default suppressed) and overriding reportCpuStarvation, but the implementation introduces starvation-checks.enabled and disables the checker via runtimeConfig. Please align the PR description (and any external references) with the actual config key name/semantics and the chosen implementation approach.

Copilot uses AI. Check for mistakes.

val defaultUserPath: Option[Path] =
object Config {
private val defaultUserPath: Option[Path] =
sys.props.get("user.home").map(Path(_).resolve(".cellar").resolve("cellar.conf"))
val defaultProjectPath: Path = Path(".cellar").resolve("cellar.conf")

def load(path: Option[Path]): IO[Config] = {
def load0(path: List[Path]) =
IO.blocking {
path.foldLeft(ConfigSource.default)((cs, p) => ConfigSource.file(p.toNioPath).withFallback(cs)).loadOrThrow[Config]
}

path match
case sp: Some[_] => load0(sp.toList)
case None =>
val relevantPaths = defaultUserPath.toList ++ List(defaultProjectPath)
relevantPaths.filterA(p => Files[IO].exists(p)).flatMap(load0)
private val defaultProjectPath: Path = Path(".cellar").resolve("cellar.conf")

lazy val global: Config = {
val paths = (defaultUserPath.toList ++ List(defaultProjectPath))
.filter(p => java.nio.file.Files.exists(p.toNioPath))
paths
.foldLeft(ConfigSource.default)((cs, p) => ConfigSource.file(p.toNioPath).withFallback(cs))
.loadOrThrow[Config]
}
}
2 changes: 1 addition & 1 deletion lib/src/cellar/build/ProjectClasspathProvider.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ object ProjectClasspathProvider:
module: Option[String],
jreClasspath: Classpath,
noCache: Boolean,
config: Config
config: Config = Config.global
): Resource[IO, (Context, Classpath)] =
Resource.eval(resolveClasspath(cwd, module, noCache, config)).flatMap { paths =>
ContextResource.make(paths, jreClasspath)
Expand Down
4 changes: 2 additions & 2 deletions lib/src/cellar/handlers/ProjectGetHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ object ProjectGetHandler:
def run(
fqn: String,
module: Option[String],
config: Config,
javaHome: Option[Path] = None,
noCache: Boolean = false,
cwd: Option[Path] = None
cwd: Option[Path] = None,
config: Config = Config.global
)(using Console[IO]): IO[ExitCode] =
ProjectHandler.run(javaHome, cwd, module, noCache, config) { (ctx, classpath, _) =>
given Context = ctx
Expand Down
2 changes: 1 addition & 1 deletion lib/src/cellar/handlers/ProjectHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ object ProjectHandler:
cwd: Option[Path],
module: Option[String],
noCache: Boolean,
config: Config
config: Config = Config.global
)(body: (Context, Classpath, Classpath) => IO[ExitCode])(using Console[IO]): IO[ExitCode] =
val program =
for
Expand Down
4 changes: 2 additions & 2 deletions lib/src/cellar/handlers/ProjectListHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ object ProjectListHandler:
fqn: String,
module: Option[String],
limit: Int,
config: Config,
javaHome: Option[Path] = None,
noCache: Boolean = false,
cwd: Option[Path] = None
cwd: Option[Path] = None,
config: Config = Config.global
)(using Console[IO]): IO[ExitCode] =
ProjectHandler.run(javaHome, cwd, module, noCache, config) { (ctx, _, _) =>
given Context = ctx
Expand Down
4 changes: 2 additions & 2 deletions lib/src/cellar/handlers/ProjectSearchHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ object ProjectSearchHandler:
query: String,
module: Option[String],
limit: Int,
config: Config,
javaHome: Option[Path] = None,
noCache: Boolean = false,
cwd: Option[Path] = None
cwd: Option[Path] = None,
config: Config = Config.global
)(using Console[IO]): IO[ExitCode] =
ProjectHandler.run(javaHome, cwd, module, noCache, config) { (ctx, classpath, jreClasspath) =>
given Context = ctx
Expand Down
26 changes: 13 additions & 13 deletions lib/test/src/cellar/ProjectAwareIntegrationTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,16 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
// --- MillBuildTool tests ---

test("MillBuildTool: rejects missing --module"):
Config.default.map(config => build.MillBuildTool(Path("."), config.mill))
.flatMap(_.extractClasspath(None)).attempt.map { result =>
build.MillBuildTool(Path("."), Config.global.mill)
.extractClasspath(None).attempt.map { result =>
assert(result.isLeft)
assert(result.left.exists(_.getMessage.contains("--module is required for Mill")))
}

// --- SbtBuildTool tests ---

test("SbtBuildTool: rejects missing --module"):
Config.default.map(config => build.SbtBuildTool(Path("."), config.sbt)).flatMap(_.extractClasspath(None))
build.SbtBuildTool(Path("."), Config.global.sbt).extractClasspath(None)
.attempt.map { result =>
assert(result.isLeft)
assert(result.left.exists(_.getMessage.contains("--module is required for sbt")))
Expand Down Expand Up @@ -299,7 +299,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
| def hello: String = "world"
|""".stripMargin
)) >>
Config.default.flatMap(handlers.ProjectGetHandler.run("example.MyClass", module = None, _, cwd = Some(dir))).map { code =>
handlers.ProjectGetHandler.run("example.MyClass", module = None, cwd = Some(dir)).map { code =>
assertEquals(code, ExitCode.Success, s"Stderr: ${console.errBuf}")
assert(console.outBuf.toString.contains("MyClass"), s"Output: ${console.outBuf}")
assert(console.outBuf.toString.contains("hello"), s"Output: ${console.outBuf}")
Expand All @@ -320,7 +320,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
| def run: Unit = ()
|""".stripMargin
)) >>
Config.default.flatMap(handlers.ProjectGetHandler.run("cats.Monad", module = None, _, cwd = Some(dir))).map { code =>
handlers.ProjectGetHandler.run("cats.Monad", module = None, cwd = Some(dir)).map { code =>
assertEquals(code, ExitCode.Success)
assert(console.outBuf.toString.contains("Monad"), s"Output: ${console.outBuf}")
}
Expand All @@ -339,7 +339,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
| def goodbye: Int = 42
|""".stripMargin
)) >>
Config.default.flatMap(handlers.ProjectListHandler.run("example.MyClass", module = None, limit = 50, _, cwd = Some(dir))).map { code =>
handlers.ProjectListHandler.run("example.MyClass", module = None, limit = 50, cwd = Some(dir)).map { code =>
assertEquals(code, ExitCode.Success)
val out = console.outBuf.toString
assert(out.contains("hello"), s"Output: $out")
Expand All @@ -359,7 +359,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
| def run: Unit = ()
|""".stripMargin
)) >>
Config.default.flatMap(handlers.ProjectSearchHandler.run("UniqueTestClassName123", module = None, limit = 50, _, cwd = Some(dir))).map { code =>
handlers.ProjectSearchHandler.run("UniqueTestClassName123", module = None, limit = 50, cwd = Some(dir)).map { code =>
assertEquals(code, ExitCode.Success)
assert(console.outBuf.toString.contains("UniqueTestClassName123"), s"Output: ${console.outBuf}")
}
Expand All @@ -375,7 +375,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
|class Foo
|""".stripMargin
)) >>
Config.default.flatMap(handlers.ProjectGetHandler.run("example.Foo", module = Some("bar"), _, cwd = Some(dir))).map { code =>
handlers.ProjectGetHandler.run("example.Foo", module = Some("bar"), cwd = Some(dir)).map { code =>
assertEquals(code, ExitCode.Error)
assert(console.errBuf.toString.contains("--module is not supported"), s"Stderr: ${console.errBuf}")
}
Expand All @@ -393,7 +393,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
|}
|""".stripMargin
)) >>
Config.default.flatMap(handlers.ProjectGetHandler.run("example.Bad", module = None, _, cwd = Some(dir))).map { code =>
handlers.ProjectGetHandler.run("example.Bad", module = None, cwd = Some(dir)).map { code =>
assertEquals(code, ExitCode.Error)
assert(console.errBuf.toString.contains("Compilation failed"), s"Stderr: ${console.errBuf}")
}
Expand Down Expand Up @@ -425,7 +425,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
|""".stripMargin
)
} >>
Config.default.flatMap(config => handlers.ProjectGetHandler.run("example.MillClass", module = Some("app"), cwd = Some(dir), config = config.copy(mill = MillConfig(millBinary)))).map { code =>
handlers.ProjectGetHandler.run("example.MillClass", module = Some("app"), cwd = Some(dir), config = Config.global.copy(mill = MillConfig(millBinary))).map { code =>
assertEquals(code, ExitCode.Success, s"Stderr: ${console.errBuf}\nStdout: ${console.outBuf}")
assert(console.outBuf.toString.contains("MillClass"), s"Output: ${console.outBuf}")
assert(console.outBuf.toString.contains("greet"), s"Output: ${console.outBuf}")
Expand All @@ -438,7 +438,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
val console = CapturingConsole()
given Console[IO] = console
IO.blocking(Files.writeString(dir.resolve("build.mill").toNioPath, "")) >>
Config.default.flatMap(config => handlers.ProjectGetHandler.run("example.Foo", module = None, cwd = Some(dir), config = config.copy(mill = MillConfig(millBinary)))).map { code =>
handlers.ProjectGetHandler.run("example.Foo", module = None, cwd = Some(dir), config = Config.global.copy(mill = MillConfig(millBinary))).map { code =>
assertEquals(code, ExitCode.Error)
assert(console.errBuf.toString.contains("--module is required for Mill"), s"Stderr: ${console.errBuf}")
}
Expand Down Expand Up @@ -468,7 +468,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
|""".stripMargin
)
} >>
Config.default.flatMap(handlers.ProjectGetHandler.run("example.SbtClass", module = Some("cellar-test"), _, cwd = Some(dir))).map { code =>
handlers.ProjectGetHandler.run("example.SbtClass", module = Some("cellar-test"), cwd = Some(dir)).map { code =>
assertEquals(code, ExitCode.Success, s"Stderr: ${console.errBuf}")
assert(console.outBuf.toString.contains("SbtClass"), s"Output: ${console.outBuf}")
}
Expand All @@ -480,7 +480,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
val console = CapturingConsole()
given Console[IO] = console
IO.blocking(Files.writeString(dir.resolve("build.sbt").toNioPath, "")) >>
Config.default.flatMap(handlers.ProjectGetHandler.run("example.Foo", module = None, _, cwd = Some(dir))).map { code =>
handlers.ProjectGetHandler.run("example.Foo", module = None, cwd = Some(dir)).map { code =>
assertEquals(code, ExitCode.Error)
assert(console.errBuf.toString.contains("--module is required for sbt"), s"Stderr: ${console.errBuf}")
}
Expand Down
Loading