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:

FormWhen 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 Greet

Greet 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 Greet

Subcommands: 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 TodoApp

TodoApp 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 --all

Or 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 --all

The test suite includes a runnable variant of this app in casetest.TodoAppFixture (store initialized via TodoAppFixture.init for tests).

API summary

TypeExtendsUse 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).

  • case-app (option definitions, help, completions)
  • KyoApp (raw args without a CLI parser)