kyo-case-app
Bridge case-app command-line parsing with Kyo application entrypoints. Use `KyoCaseApp` for single-command apps and `KyoCommand` for subcommands grouped via case-app's CommandsEntryPoint.
Register effectful work with `run` after case-app parses the command line. Unlike KyoApp, there are no implicit options / remainingArgs accessors. Parsed data is passed explicitly via overloads:
| Form | When to use |
|---|---|
| `run { options => ... }` | Recommended for typical CLI logic using parsed flags and options |
run { (options, remainingArgs) => ... } | You also need leftover positionals (RemainingArgs) |
run { ... } | The effect does not use parsed CLI data (startup hooks, etc.) |
All three overloads share one registration queue: multiple run blocks run in object-initialization order, and each block sees the same parse result from that main invocation.
case-app handles help, usage, and argument parsing. This module runs your effects after parsing completes. For how to define options (annotations, defaults, subcommand metadata, etc.), see the case-app documentation. That material is not repeated here.
Works on JVM, Scala.js, and Scala Native.
Getting Started
Add the dependency to your build.sbt:
libraryDependencies += "io.getkyo" %%% "kyo-case-app" % "<latest version>"You also need a case-app dependency if you use its annotations or helpers directly:
libraryDependencies += "com.github.alexarchambault" %%% "case-app" % "2.1.0"Single command: KyoCaseApp
Define a case class for options (see case-app for field annotations and parsers), then extend KyoCaseApp:
import caseapp.*
import kyo.*
final case class GreetOptions(
@Name("name") name: String = "world"
)
object Greet extends KyoCaseApp[GreetOptions]:
run { options =>
Console.printLine(s"Hello, ${options.name}!")
}
end GreetGreet already inherits main(args: Array[String]) from case-app. The run block does not replace main. It registers the Kyo effects that main runs after parsing CLI arguments (the same relationship as run on KyoApp).
Note: at least one run block is required. An app that registers none does not silently do nothing: at runtime it prints nothing to execute. Did you forget to use a run block? and exits with code 1.
Point your build at that object as the main class, for example in sbt:
Compile / mainClass := Some("Greet")Then run / assembly / your packager invokes Greet.main directly. You do not need a separate @main method that forwards to Greet.main(args).
During development, pass CLI args to sbt after -- (everything after -- is forwarded to main):
sbt run -- --name Alice
# Hello, Alice!
sbt run -- Bob extra-arg
# Hello, Bob!
# remainingArgs.remaining == Seq("extra-arg")You can also name the main class explicitly:
sbt "runMain Greet -- --name Alice"With a packaged binary (for example after assembly / nativeImage / scala-cli --assembly), invoke the artifact directly:
./greet --name Alice
# Hello, Alice!
./greet Bob extra-arg
# Hello, Bob!You can mix overloads in one app, and registration order is preserved:
object Greet extends KyoCaseApp[GreetOptions]:
run { options => Console.printLine(s"Hello, ${options.name}!") } // recommended
run { (options, remainingArgs) => // when positionals matter
Console.printLine(s"extras: ${remainingArgs.remaining.mkString(" ")}")
}
run { Console.printLine("starting") } // no CLI params
end GreetSubcommands: KyoCommand
Each subcommand is a `object` extending KyoCommand. Group them with case-app's CommandsEntryPoint:
import caseapp.*
import caseapp.core.app.CommandsEntryPoint
import kyo.*
enum TodoStatus:
case Pending, Active, Completed
final case class Todo(id: Int, title: String, status: TodoStatus)
final case class CreateOptions(@Name("title") title: Option[String] = None)
final case class IdOptions(@Name("id") id: Option[Int] = None)
final case class ListOptions(@Name("all") all: Boolean = false)
object TodoApp extends CommandsEntryPoint:
// CommandsEntryPoint has no effectful setup hook, so the store is initialized
// outside the effect system using the Unsafe API.
private val store =
import AllowUnsafe.embrace.danger
AtomicRef.Unsafe.init(Chunk.empty[Todo]).safe
override def progName: String = "todo"
def commands = Seq(Create, Complete, List, Delete, Start)
object Create extends KyoCommand[CreateOptions]:
override def name = "create"
run { (options, remainingArgs) =>
val title = options.title.orElse(remainingArgs.remaining.headOption).getOrElse {
throw new IllegalArgumentException("create requires --title or a positional title")
}
for
todos <- store.get
id = todos.map(_.id).maxOption.getOrElse(0) + 1
_ <- store.set(todos.appended(Todo(id, title, TodoStatus.Pending)))
_ <- Console.printLine(s"created #$id: $title")
yield ()
end for
}
end Create
object Complete extends KyoCommand[IdOptions]:
override def name = "complete"
run { (options, remainingArgs) =>
val id = options.id.orElse(remainingArgs.remaining.headOption.flatMap(_.toIntOption)).get
for
todos <- store.get
todo <- todos.find(_.id == id).map(Sync.defer(_)).getOrElse(Abort.fail(new NoSuchElementException(s"no todo #$id")))
_ <- store.set(todos.map(t => if t.id == id then t.copy(status = TodoStatus.Completed) else t))
_ <- Console.printLine(s"completed #$id: ${todo.title}")
yield ()
end for
}
end Complete
object List extends KyoCommand[ListOptions]:
override def name = "list"
run { options =>
for
todos <- store.get
visible <- Sync.defer(if options.all then todos else todos.filter(t => t.status ne TodoStatus.Completed))
_ <- if visible.isEmpty then Console.printLine("no todos")
else Async.foreachDiscard(visible)(t => Console.printLine(render(t)))
yield ()
}
end List
object Delete extends KyoCommand[IdOptions]:
override def name = "delete"
run { (options, remainingArgs) =>
val id = options.id.orElse(remainingArgs.remaining.headOption.flatMap(_.toIntOption)).get
for
todos <- store.get
todo <- todos.find(_.id == id).map(Sync.defer(_)).getOrElse(Abort.fail(new NoSuchElementException(s"no todo #$id")))
_ <- store.set(todos.filterNot(_.id == id))
_ <- Console.printLine(s"deleted #$id: ${todo.title}")
yield ()
end for
}
end Delete
object Start extends KyoCommand[IdOptions]:
override def name = "start"
run { (options, remainingArgs) =>
val id = options.id.orElse(remainingArgs.remaining.headOption.flatMap(_.toIntOption)).get
for
todos <- store.get
todo <- todos.find(_.id == id).map(Sync.defer(_)).getOrElse(Abort.fail(new NoSuchElementException(s"no todo #$id")))
_ <-
if todo.status eq TodoStatus.Active then
Console.printLine(s"todo #$id already active")
else if todo.status eq TodoStatus.Completed then
Abort.fail(new IllegalStateException(s"already completed"))
else
store.set(todos.map(t => if t.id == id then t.copy(status = TodoStatus.Active) else t))
.andThen(Console.printLine(s"started #$id: ${todo.title}"))
yield ()
end for
}
end Start
private def render(todo: Todo): String =
val mark =
if todo.status eq TodoStatus.Pending then "[ ]"
else if todo.status eq TodoStatus.Active then "[~]"
else if todo.status eq TodoStatus.Completed then "[x]"
else "[?]"
s"$mark #${todo.id} ${todo.title}"
end render
end TodoAppTodoApp likewise inherits main from CommandsEntryPoint. Set Compile / mainClass := Some("TodoApp") in sbt.
During development:
sbt run -- create --title "Buy milk"
sbt run -- create "Walk dog"
sbt run -- start 1
sbt run -- list
# [~] #1 Buy milk
# [ ] #2 Walk dog
sbt run -- complete 2
sbt run -- list --allOr with an explicit main class:
sbt "runMain TodoApp -- create --title Buy milk"With a packaged binary:
./todo create --title "Buy milk"
./todo create "Walk dog"
./todo start 1
./todo list
# [~] #1 Buy milk
# [ ] #2 Walk dog
./todo complete 2
./todo list --allThe test suite includes a runnable variant of this app in casetest.TodoAppFixture (store initialized via TodoAppFixture.init for tests).
API summary
| Type | Extends | Use when |
|---|---|---|
KyoCaseApp[T] | caseapp.CaseApp[T] | One options type, one entrypoint |
KyoCommand[T] | caseapp.core.app.Command[T] | Subcommand with its own options type |
Both provide three run overloads (same names, resolved by the shape of the block):
- `run { options => ... }` (recommended for most commands)
run { (options, remainingArgs) => ... }(when leftover positionals matter)run { ... }(effect without parsed CLI data)
All delegate to a single registerRun queue so mixed overloads keep registration order.
Failure and signals
On failure, the behavior depends on the error type. A Throwable failure propagates out of main as a normal JVM stack-trace-and-crash. A non-Throwable Abort failure routes through exitHook(1), which defaults to Platform.exit (case-app reserves exit for its own API). Interrupt handling is shared with KyoApp via KyoAppRunner (SIGINT/SIGTERM on non-Windows platforms).