Repository: scala-steward-org/scala-steward
Pull request #2414 (2021-12-19 17:19:52)

Replace case-app with decline
This replaces [case-app](https://github.com/alexarchambault/case-app) with [decline](https://ben.kirw.in/decline/) for command-line arguments parsing. My main motivation for doing this was to get familiar with decline but it also has some benefits:

* Help messages are ordinary values instead of annotations of fields. This means we can use variables and string interpolation in those messages which is currently not possible in annotation arguments. The biggest benefit here is that we don't need to repeat default values verbatim.
* Relationships between different options can be expressed. For example, `--enable-sandbox` and `--disable-sandbox` are now mutually exclusive and it is a runtime error if both are specified. Similar, if one of the GitHub app option is specified, the other must be specified too and not doing so is again a runtime error.
* decline does not use macros and therefore is already available for Scala 3:
  * https://repo1.maven.org/maven2/com/github/alexarchambault/
  * https://repo1.maven.org/maven2/com/monovore/decline_3/

The only downside I'm seeing so far is that defining the options is a little bit more work than than writing the `Args` case class.

codecov[bot]: # [Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) Report
> Merging [#2414](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) (46d8ffc) into [master](https://codecov.io/gh/scala-steward-org/scala-steward/commit/cd590e2561697ae067d47355dcc8c4405295dbaf?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) (cd590e2) will **increase** coverage by `0.45%`.
> The diff coverage is `97.45%`.

> :exclamation: Current head 46d8ffc differs from pull request most recent head 971b4a3. Consider uploading reports for the commit 971b4a3 to get more accurate results
[![Impacted file tree graph](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414/graphs/tree.svg?width=650&height=150&src=pr&token=udzMsc1Dne&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org)](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org)

```diff
@@            Coverage Diff             @@
##           master    #2414      +/-   ##
==========================================
+ Coverage   80.30%   80.76%   +0.45%     
==========================================
  Files         141      141              
  Lines        2392     2444      +52     
  Branches       47       51       +4     
==========================================
+ Hits         1921     1974      +53     
+ Misses        471      470       -1     
```


| [Impacted Files](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) | Coverage Δ | |
|---|---|---|
| [...re/src/main/scala/org/scalasteward/core/Main.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9NYWluLnNjYWxh) | `0.00% <0.00%> (ø)` | |
| [...ala/org/scalasteward/core/application/Config.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9hcHBsaWNhdGlvbi9Db25maWcuc2NhbGE=) | `100.00% <ø> (+2.32%)` | :arrow_up: |
| [...la/org/scalasteward/core/application/Context.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9hcHBsaWNhdGlvbi9Db250ZXh0LnNjYWxh) | `70.58% <ø> (+1.02%)` | :arrow_up: |
| [...main/scala/org/scalasteward/core/vcs/VCSType.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS92Y3MvVkNTVHlwZS5zY2FsYQ==) | `100.00% <ø> (ø)` | |
| [.../scala/org/scalasteward/core/application/Cli.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9hcHBsaWNhdGlvbi9DbGkuc2NhbGE=) | `100.00% <100.00%> (ø)` | |
| [...in/scala/org/scalasteward/core/util/dateTime.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS91dGlsL2RhdGVUaW1lLnNjYWxh) | `96.42% <100.00%> (-3.58%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org). Last update [cd590e2...971b4a3](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2414?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).


Patch:
diff --git a/build.sbt b/build.sbt
index 22493bde..5087275c 100644
--- a/build.sbt
+++ b/build.sbt
@@ -84,7 +84,6 @@ lazy val core = myCrossProject("core")
     libraryDependencies ++= Seq(
       Dependencies.bcprovJdk15to18,
       Dependencies.betterFiles,
-      Dependencies.caseApp,
       Dependencies.catsCore,
       Dependencies.catsEffect,
       Dependencies.catsParse,
@@ -96,6 +95,7 @@ lazy val core = myCrossProject("core")
       Dependencies.commonsIo,
       Dependencies.coursierCore,
       Dependencies.cron4sCore,
+      Dependencies.decline,
       Dependencies.fs2Core,
       Dependencies.fs2Io,
       Dependencies.http4sCirce,
diff --git a/docs/help.md b/docs/help.md
index af9d08db..a2b79d7d 100644
--- a/docs/help.md
+++ b/docs/help.md
@@ -3,40 +3,77 @@
 All command line arguments for the `scala-steward` application.
 
 ```
-Usage: args [options]
+Usage: scala-steward --workspace <file> --repos-file <file> [--git-author-name <string>] --git-author-email <string> [--git-author-signing-key <string>] --git-ask-pass <file> [--sign-commits] [--vcs-type <vcs-type>] [--vcs-api-host <uri>] --vcs-login <string> [--do-not-fork] [--ignore-opts-files] [--env-var <name=value>]... [--process-timeout <duration>] [--whitelist <string>]... [--read-only <string>]... [--enable-sandbox | --disable-sandbox] [--max-buffer-size <integer>] [--repo-config <uri>]... [--disable-default-repo-config] [--scalafix-migrations <uri>]... [--disable-default-scalafix-migrations] [--artifact-migrations <uri>]... [--disable-default-artifact-migrations] [--cache-ttl <duration>] [--bitbucket-server-use-default-reviewers] [--gitlab-merge-when-pipeline-succeeds] [--github-app-id <integer> --github-app-key-file <file>] [--url-checker-test-url <uri>] [--default-maven-repo <string>] [--refresh-backoff-period <duration>]
 
-Options:
-  --workspace file                          Location for cache and temporary files
-  --repos-file file                         A markdown formatted file with a repository list
-  --git-author-name string                  Git "user.name", default: "Scala Steward"
-  --git-author-email string                 Email address of the git user
-  --git-author-signing-key string?          Git "user.signingKey"
-  --vcs-type vcs-type                       One of "github", "gitlab", "bitbucket" or "bitbucket-server", default: "github"
-  --vcs-api-host uri                        API uri of the git hoster, default: "https://api.github.com"
-  --vcs-login string                        The user name for the git hoster
-  --git-ask-pass file                       An executable file that returns the git credentials
-  --sign-commits                            Whether to sign commits, default: "false"
-  --whitelist string*                       Directory white listed for the sandbox (can be used multiple times)
-  --read-only string*                       Read only directory for the sandbox (can be used multiple times)
-  --enable-sandbox                          Whether to use the sandbox, overwrites "--disable-sandbox", default: "false"
-  --disable-sandbox                         Whether to not use the sandbox, default: "true"
-  --do-not-fork                             Whether to not push the update branches to a fork, default: "false"
-  --ignore-opts-files                       Whether to remove ".jvmopts" and ".sbtopts" files before invoking the build tool
-  --env-var name=value*                     Assigns the value to the environment variable name (can be used multiple times)
-  --process-timeout duration                Timeout for external process invocations, default: "10min"
-  --max-buffer-size int                     Size of the buffer for the output of an external process in lines, default: "8192"
-  --repo-config uri*                        Additional repo config file (can be used multiple times)
-  --disable-default-repo-config             Whether to disable the default repo config file
-  --scalafix-migrations uri*                Additional scalafix migrations configuration file (can be used multiple times)
-  --disable-default-scalafix-migrations     Whether to disable the default scalafix migration file
-  --artifact-migrations uri*                Additional artifact migration configuration file (can be used multiple times)
-  --disable-default-artifact-migrations     Whether to disable the default artifact migration file
-  --cache-ttl duration                      TTL for the caches, default: "2hours"
-  --bitbucket-server-use-default-reviewers  Whether to assign the default reviewers to a bitbucket pull request, default: "false"
-  --gitlab-merge-when-pipeline-succeeds     Whether to merge a gitlab merge request when the pipeline succeeds
-  --github-app-key-file file?               GitHub application key file
-  --github-app-id long?                     GitHub application id
-  --url-checker-test-url uri?
-  --default-maven-repo string?
-  --refresh-backoff-period duration         Period of time a failed build won't be triggered again, default: "0days"
+
+
+Options and flags:
+    --help
+        Display this help text.
+    --workspace <file>
+        Location for cache and temporary files
+    --repos-file <file>
+        A markdown formatted file with a repository list
+    --git-author-name <string>
+        Git "user.name"; default: Scala Steward
+    --git-author-email <string>
+        Git "user.email"
+    --git-author-signing-key <string>
+        Git "user.signingKey"
+    --git-ask-pass <file>
+        An executable file that returns the git credentials
+    --sign-commits
+        Whether to sign commits; default: false
+    --vcs-type <vcs-type>
+        One of bitbucket, bitbucket-server, github, gitlab; default: github
+    --vcs-api-host <uri>
+        API URL of the git hoster; default: https://api.github.com
+    --vcs-login <string>
+        The user name for the git hoster
+    --do-not-fork
+        Whether to not push the update branches to a fork; default: false
+    --ignore-opts-files
+        Whether to remove ".jvmopts" and ".sbtopts" files before invoking the build tool
+    --env-var <name=value>
+        Assigns the value to the environment variable name (can be used multiple times)
+    --process-timeout <duration>
+        Timeout for external process invocations; default: 10minutes
+    --whitelist <string>
+        Directory white listed for the sandbox (can be used multiple times)
+    --read-only <string>
+        Read only directory for the sandbox (can be used multiple times)
+    --enable-sandbox
+        Whether to use the sandbox
+    --disable-sandbox
+        Whether to not use the sandbox
+    --max-buffer-size <integer>
+        Size of the buffer for the output of an external process in lines; default: 8192
+    --repo-config <uri>
+        Additional repo config file (can be used multiple times)
+    --disable-default-repo-config
+        Whether to disable the default repo config file
+    --scalafix-migrations <uri>
+        Additional scalafix migrations configuration file (can be used multiple times)
+    --disable-default-scalafix-migrations
+        Whether to disable the default scalafix migration file; default: false
+    --artifact-migrations <uri>
+        Additional artifact migration configuration file (can be used multiple times)
+    --disable-default-artifact-migrations
+        Whether to disable the default artifact migration file
+    --cache-ttl <duration>
+        TTL for the caches; default: 2hours
+    --bitbucket-server-use-default-reviewers
+        Whether to assign the default reviewers to a bitbucket pull request; default: false
+    --gitlab-merge-when-pipeline-succeeds
+        Whether to merge a gitlab merge request when the pipeline succeeds
+    --github-app-id <integer>
+        GitHub application id
+    --github-app-key-file <file>
+        GitHub application key file
+    --url-checker-test-url <uri>
+        default: https://github.com
+    --default-maven-repo <string>
+        default: https://repo1.maven.org/maven2/
+    --refresh-backoff-period <duration>
+        Period of time a failed build won't be triggered again; default: 0days
 ```
diff --git a/modules/core/src/main/scala/org/scalasteward/core/Main.scala b/modules/core/src/main/scala/org/scalasteward/core/Main.scala
index 74409f0d..eb1bcb9f 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/Main.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/Main.scala
@@ -16,14 +16,15 @@
 
 package org.scalasteward.core
 
+import cats.effect.std.Console
 import cats.effect.{ExitCode, IO, IOApp}
 import org.scalasteward.core.application.{Cli, Context}
 
 object Main extends IOApp {
   override def run(args: List[String]): IO[ExitCode] =
     Cli.parseArgs(args) match {
-      case Cli.ParseResult.Success(args) => Context.step0[IO](args).use(_.stewardAlg.runF)
-      case Cli.ParseResult.Help(help)    => IO(Console.out.println(help)).as(ExitCode.Success)
-      case Cli.ParseResult.Error(error)  => IO(Console.err.println(error)).as(ExitCode.Error)
+      case Cli.ParseResult.Success(config) => Context.step0[IO](config).use(_.stewardAlg.runF)
+      case Cli.ParseResult.Help(help)      => Console[IO].println(help).as(ExitCode.Success)
+      case Cli.ParseResult.Error(error)    => Console[IO].errorln(error).as(ExitCode.Error)
     }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
index c91fc3f6..5091cc3d 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
@@ -17,150 +17,270 @@
 package org.scalasteward.core.application
 
 import better.files.File
-import caseapp._
-import caseapp.core.Error.MalformedValue
-import caseapp.core.argparser.{ArgParser, SimpleArgParser}
+import cats.data.Validated
 import cats.syntax.all._
+import com.monovore.decline.Opts.{flag, option, options}
+import com.monovore.decline._
 import org.http4s.Uri
-import org.scalasteward.core.util.dateTime.parseFiniteDuration
+import org.http4s.syntax.literals._
+import org.scalasteward.core.application.Config._
+import org.scalasteward.core.data.Resolver
+import org.scalasteward.core.git.Author
+import org.scalasteward.core.util.dateTime.renderFiniteDuration
 import org.scalasteward.core.vcs.VCSType
+import org.scalasteward.core.vcs.VCSType.GitHub
+import org.scalasteward.core.vcs.github.GitHubApp
 import scala.concurrent.duration._
 
 object Cli {
-  final case class Args(
-      @HelpMessage("Location for cache and temporary files")
-      workspace: File,
-      @HelpMessage("A markdown formatted file with a repository list")
-      reposFile: File,
-      @HelpMessage("Git \"user.name\", default: \"Scala Steward\"")
-      gitAuthorName: String = "Scala Steward",
-      @HelpMessage("Email address of the git user")
-      gitAuthorEmail: String,
-      @HelpMessage("Git \"user.signingKey\"")
-      gitAuthorSigningKey: Option[String] = None,
-      @HelpMessage(
-        "One of \"github\", \"gitlab\", \"bitbucket\" or \"bitbucket-server\", default: \"github\""
-      )
-      vcsType: VCSType = VCSType.GitHub,
-      @HelpMessage("API uri of the git hoster, default: \"https://api.github.com\"")
-      vcsApiHost: Uri = VCSType.GitHub.publicApiBaseUrl,
-      @HelpMessage("The user name for the git hoster")
-      vcsLogin: String,
-      @HelpMessage("An executable file that returns the git credentials")
-      gitAskPass: File,
-      @HelpMessage("Whether to sign commits, default: \"false\"")
-      signCommits: Boolean = false,
-      @HelpMessage("Directory white listed for the sandbox (can be used multiple times)")
-      whitelist: List[String] = Nil,
-      @HelpMessage("Read only directory for the sandbox (can be used multiple times)")
-      readOnly: List[String] = Nil,
-      @HelpMessage(
-        "Whether to use the sandbox, overwrites \"--disable-sandbox\", default: \"false\""
-      )
-      enableSandbox: Option[Boolean] = None,
-      @HelpMessage("Whether to not use the sandbox, default: \"true\"")
-      disableSandbox: Boolean = true,
-      @HelpMessage("Whether to not push the update branches to a fork, default: \"false\"")
-      doNotFork: Boolean = false,
-      @HelpMessage(
-        "Whether to remove \".jvmopts\" and \".sbtopts\" files before invoking the build tool"
-      )
-      ignoreOptsFiles: Boolean = false,
-      @HelpMessage(
-        "Assigns the value to the environment variable name (can be used multiple times)"
-      )
-      envVar: List[EnvVar] = Nil,
-      @HelpMessage("Timeout for external process invocations, default: \"10min\"")
-      processTimeout: FiniteDuration = 10.minutes,
-      @HelpMessage(
-        "Size of the buffer for the output of an external process in lines, default: \"8192\""
-      )
-      maxBufferSize: Int = 8192,
-      @HelpMessage("Additional repo config file (can be used multiple times)")
-      repoConfig: List[Uri] = Nil,
-      @HelpMessage("Whether to disable the default repo config file")
-      disableDefaultRepoConfig: Boolean = false,
-      @HelpMessage("Additional scalafix migrations configuration file (can be used multiple times)")
-      scalafixMigrations: List[Uri] = Nil,
-      @HelpMessage("Whether to disable the default scalafix migration file")
-      disableDefaultScalafixMigrations: Boolean = false,
-      @HelpMessage("Additional artifact migration configuration file (can be used multiple times)")
-      artifactMigrations: List[Uri] = Nil,
-      @HelpMessage("Whether to disable the default artifact migration file")
-      disableDefaultArtifactMigrations: Boolean = false,
-      @HelpMessage("TTL for the caches, default: \"2hours\"")
-      cacheTtl: FiniteDuration = 2.hours,
-      @HelpMessage(
-        "Whether to assign the default reviewers to a bitbucket pull request, default: \"false\""
-      )
-      bitbucketServerUseDefaultReviewers: Boolean = false,
-      @HelpMessage("Whether to merge a gitlab merge request when the pipeline succeeds")
-      gitlabMergeWhenPipelineSucceeds: Boolean = false,
-      @HelpMessage("GitHub application key file")
-      githubAppKeyFile: Option[File] = None,
-      @HelpMessage("GitHub application id")
-      githubAppId: Option[Long] = None,
-      urlCheckerTestUrl: Option[Uri] = None,
-      defaultMavenRepo: Option[String] = None,
-      @HelpMessage("Period of time a failed build won't be triggered again, default: \"0days\"")
-      refreshBackoffPeriod: FiniteDuration = 0.days
-  )
-
   final case class EnvVar(name: String, value: String)
 
-  sealed trait ParseResult extends Product with Serializable
-  object ParseResult {
-    final case class Success(args: Args) extends ParseResult
-    final case class Help(help: String) extends ParseResult
-    final case class Error(error: String) extends ParseResult
-  }
-
-  def parseArgs(args: List[String]): ParseResult = {
-    val help = caseapp.core.help
-      .Help[Args]
-      .withAppName("Scala Steward")
-      .withAppVersion(org.scalasteward.core.BuildInfo.version)
-    CaseApp.parseWithHelp[Args](args) match {
-      case Right((_, true, _, _))        => ParseResult.Help(help.help)
-      case Right((_, _, true, _))        => ParseResult.Help(help.usage)
-      case Right((Right(args), _, _, _)) => ParseResult.Success(args)
-      case Right((Left(error), _, _, _)) => ParseResult.Error(error.message)
-      case Left(error)                   => ParseResult.Error(error.message)
-    }
-  }
-
-  implicit val envVarArgParser: ArgParser[EnvVar] =
-    SimpleArgParser.from("name=value") { s =>
+  implicit val envVarArgument: Argument[EnvVar] =
+    Argument.from("name=value") { s =>
       s.trim.split('=').toList match {
         case name :: (value @ _ :: _) =>
-          Right(EnvVar(name.trim, value.mkString("=").trim))
+          Validated.valid(EnvVar(name.trim, value.mkString("=").trim))
         case _ =>
-          val error =
-            "The value is expected in the following format: NAME=VALUE."
-          Left(MalformedValue("EnvVar", error))
+          val error = "The value is expected in the following format: NAME=VALUE."
+          Validated.invalidNel(error)
       }
     }
 
-  implicit val fileArgParser: ArgParser[File] =
-    SimpleArgParser.from("file") { s =>
-      Either.catchNonFatal(File(s)).leftMap(t => MalformedValue("File", t.getMessage))
+  implicit val fileArgument: Argument[File] =
+    Argument.from("file") { s =>
+      Validated.catchNonFatal(File(s)).leftMap(_.getMessage).toValidatedNel
     }
 
-  implicit val finiteDurationArgParser: ArgParser[FiniteDuration] =
-    SimpleArgParser.from("duration") { s =>
-      parseFiniteDuration(s).leftMap { throwable =>
-        val error = s"The value is expected in the following format: <length><unit>. ($throwable)"
-        MalformedValue("FiniteDuration", error)
-      }
+  implicit val uriArgument: Argument[Uri] =
+    Argument.from("uri") { s =>
+      Validated.fromEither(Uri.fromString(s).leftMap(_.message)).toValidatedNel
     }
 
-  implicit val vcsTypeArgParser: ArgParser[VCSType] =
-    SimpleArgParser.from("vcs-type") { s =>
-      VCSType.parse(s).leftMap(error => MalformedValue("VCSType", error))
+  implicit val vcsTypeArgument: Argument[VCSType] =
+    Argument.from("vcs-type") { s =>
+      Validated.fromEither(VCSType.parse(s)).toValidatedNel
     }
 
-  implicit val uriArgParser: ArgParser[Uri] =
-    SimpleArgParser.from("uri") { s =>
-      Uri.fromString(s).leftMap(pf => MalformedValue("Uri", pf.message))
+  private val workspace: Opts[File] =
+    option[File]("workspace", "Location for cache and temporary files")
+
+  private val reposFile: Opts[File] =
+    option[File]("repos-file", "A markdown formatted file with a repository list")
+
+  private val gitAuthorName: Opts[String] = {
+    val default = "Scala Steward"
+    option[String]("git-author-name", s"""Git "user.name"; default: $default""")
+      .withDefault(default)
+  }
+
+  private val gitAuthorEmail: Opts[String] =
+    option[String]("git-author-email", """Git "user.email"""")
+
+  private val gitAuthorSigningKey: Opts[Option[String]] =
+    option[String]("git-author-signing-key", """Git "user.signingKey"""").orNone
+
+  private val gitAuthor: Opts[Author] =
+    (gitAuthorName, gitAuthorEmail, gitAuthorSigningKey).mapN(Author.apply)
+
+  private val gitAskPass: Opts[File] =
+    option[File]("git-ask-pass", "An executable file that returns the git credentials")
+
+  private val signCommits: Opts[Boolean] =
+    flag("sign-commits", "Whether to sign commits; default: false").orFalse
+
+  private val gitCfg: Opts[GitCfg] =
+    (gitAuthor, gitAskPass, signCommits).mapN(GitCfg.apply)
+
+  private val vcsType = {
+    val help = VCSType.all.map(_.asString).mkString("One of ", ", ", "") +
+      s"; default: ${GitHub.asString}"
+    option[VCSType]("vcs-type", help).withDefault(GitHub)
+  }
+
+  private val vcsApiHost: Opts[Uri] =
+    option[Uri]("vcs-api-host", s"API URL of the git hoster; default: ${GitHub.publicApiBaseUrl}")
+      .withDefault(GitHub.publicApiBaseUrl)
+
+  private val vcsLogin: Opts[String] =
+    option[String]("vcs-login", "The user name for the git hoster")
+
+  private val doNotFork: Opts[Boolean] =
+    flag("do-not-fork", "Whether to not push the update branches to a fork; default: false").orFalse
+
+  private val vcsCfg: Opts[VCSCfg] =
+    (vcsType, vcsApiHost, vcsLogin, doNotFork).mapN(VCSCfg.apply)
+
+  private val ignoreOptsFiles: Opts[Boolean] =
+    flag(
+      "ignore-opts-files",
+      """Whether to remove ".jvmopts" and ".sbtopts" files before invoking the build tool"""
+    ).orFalse
+
+  private val envVar: Opts[List[EnvVar]] = {
+    val help = "Assigns the value to the environment variable name (can be used multiple times)"
+    options[EnvVar]("env-var", help).orEmpty
+  }
+
+  private val processTimeout: Opts[FiniteDuration] = {
+    val default = 10.minutes
+    val help =
+      s"Timeout for external process invocations; default: ${renderFiniteDuration(default)}"
+    option[FiniteDuration]("process-timeout", help).withDefault(default)
+  }
+
+  private val whitelist: Opts[List[String]] =
+    options[String](
+      "whitelist",
+      "Directory white listed for the sandbox (can be used multiple times)"
+    ).orEmpty
+
+  private val readOnly: Opts[List[String]] =
+    options[String](
+      "read-only",
+      "Read only directory for the sandbox (can be used multiple times)"
+    ).orEmpty
+
+  private val enableSandbox: Opts[Boolean] =
+    flag("enable-sandbox", "Whether to use the sandbox").orFalse
+      .orElse(flag("disable-sandbox", "Whether to not use the sandbox").orTrue.map(!_))
+
+  private val sandboxCfg: Opts[SandboxCfg] =
+    (whitelist, readOnly, enableSandbox).mapN(SandboxCfg.apply)
+
+  private val maxBufferSize: Opts[Int] = {
+    val default = 8192
+    val help =
+      s"Size of the buffer for the output of an external process in lines; default: $default"
+    option[Int]("max-buffer-size", help).withDefault(default)
+  }
+
+  private val processCfg: Opts[ProcessCfg] =
+    (envVar, processTimeout, sandboxCfg, maxBufferSize).mapN(ProcessCfg.apply)
+
+  private val repoConfig: Opts[List[Uri]] =
+    options[Uri]("repo-config", "Additional repo config file (can be used multiple times)").orEmpty
+
+  private val disableDefaultRepoConfig: Opts[Boolean] =
+    flag("disable-default-repo-config", "Whether to disable the default repo config file").orFalse
+
+  private val repoConfigCfg: Opts[RepoConfigCfg] =
+    (repoConfig, disableDefaultRepoConfig).mapN(RepoConfigCfg.apply)
+
+  private val scalafixMigrations: Opts[List[Uri]] =
+    options[Uri](
+      "scalafix-migrations",
+      "Additional scalafix migrations configuration file (can be used multiple times)"
+    ).orEmpty
+
+  private val disableDefaultScalafixMigrations: Opts[Boolean] =
+    flag(
+      "disable-default-scalafix-migrations",
+      "Whether to disable the default scalafix migration file; default: false"
+    ).orFalse
+
+  private val scalafixCfg: Opts[ScalafixCfg] =
+    (scalafixMigrations, disableDefaultScalafixMigrations).mapN(ScalafixCfg.apply)
+
+  private val artifactMigrations: Opts[List[Uri]] =
+    options[Uri](
+      "artifact-migrations",
+      "Additional artifact migration configuration file (can be used multiple times)"
+    ).orEmpty
+
+  private val disableDefaultArtifactMigrations: Opts[Boolean] =
+    flag(
+      "disable-default-artifact-migrations",
+      "Whether to disable the default artifact migration file"
+    ).orFalse
+
+  private val artifactCfg: Opts[ArtifactCfg] =
+    (artifactMigrations, disableDefaultArtifactMigrations).mapN(ArtifactCfg.apply)
+
+  private val cacheTtl: Opts[FiniteDuration] = {
+    val default = 2.hours
+    val help = s"TTL for the caches; default: ${renderFiniteDuration(default)}"
+    option[FiniteDuration]("cache-ttl", help).withDefault(default)
+  }
+
+  private val bitbucketServerUseDefaultReviewers: Opts[Boolean] =
+    flag(
+      "bitbucket-server-use-default-reviewers",
+      "Whether to assign the default reviewers to a bitbucket pull request; default: false"
+    ).orFalse
+
+  private val bitbucketServerCfg: Opts[BitbucketServerCfg] =
+    bitbucketServerUseDefaultReviewers.map(BitbucketServerCfg.apply)
+
+  private val gitlabMergeWhenPipelineSucceeds: Opts[Boolean] =
+    flag(
+      "gitlab-merge-when-pipeline-succeeds",
+      "Whether to merge a gitlab merge request when the pipeline succeeds"
+    ).orFalse
+
+  private val gitLabCfg: Opts[GitLabCfg] =
+    gitlabMergeWhenPipelineSucceeds.map(GitLabCfg.apply)
+
+  private val githubAppId: Opts[Long] =
+    option[Long]("github-app-id", "GitHub application id")
+
+  private val githubAppKeyFile: Opts[File] =
+    option[File]("github-app-key-file", "GitHub application key file")
+
+  private val gitHubApp: Opts[Option[GitHubApp]] =
+    (githubAppId, githubAppKeyFile).mapN(GitHubApp.apply).orNone
+
+  private val refreshBackoffPeriod: Opts[FiniteDuration] = {
+    val default = 0.days
+    val help = "Period of time a failed build won't be triggered again" +
+      s"; default: ${renderFiniteDuration(default)}"
+    option[FiniteDuration]("refresh-backoff-period", help).withDefault(default)
+  }
+
+  private val urlCheckerTestUrl: Opts[Uri] = {
+    val default = uri"https://github.com"
+    option[Uri]("url-checker-test-url", s"default: $default").withDefault(default)
+  }
+
+  private val defaultMavenRepo: Opts[Resolver] = {
+    val default = Resolver.mavenCentral
+    option[String]("default-maven-repo", s"default: ${default.location}")
+      .map(location => Resolver.MavenRepository("default", location, None))
+      .withDefault(default)
+  }
+
+  private val configOpts: Opts[Config] = (
+    workspace,
+    reposFile,
+    gitCfg,
+    vcsCfg,
+    ignoreOptsFiles,
+    processCfg,
+    repoConfigCfg,
+    scalafixCfg,
+    artifactCfg,
+    cacheTtl,
+    bitbucketServerCfg,
+    gitLabCfg,
+    gitHubApp,
+    urlCheckerTestUrl,
+    defaultMavenRepo,
+    refreshBackoffPeriod
+  ).mapN(Config.apply)
+
+  val command: Command[Config] =
+    Command("scala-steward", "")(configOpts)
+
+  sealed trait ParseResult extends Product with Serializable
+  object ParseResult {
+    final case class Success(config: Config) extends ParseResult
+    final case class Help(help: String) extends ParseResult
+    final case class Error(error: String) extends ParseResult
+  }
+
+  def parseArgs(args: List[String]): ParseResult =
+    command.parse(args) match {
+      case Left(help) if help.errors.isEmpty => ParseResult.Help(help.toString)
+      case Left(help)                        => ParseResult.Error(help.toString)
+      case Right(config)                     => ParseResult.Success(config)
     }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
index 6d2a1dde..f90fa1cf 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
@@ -17,11 +17,10 @@
 package org.scalasteward.core.application
 
 import better.files.File
+import cats.Monad
 import cats.syntax.all._
-import cats.{Apply, Monad}
 import org.http4s.Uri
 import org.http4s.Uri.UserInfo
-import org.http4s.syntax.literals._
 import org.scalasteward.core.application.Cli.EnvVar
 import org.scalasteward.core.application.Config._
 import org.scalasteward.core.data.Resolver
@@ -135,57 +134,4 @@ object Config {
   final case class GitLabCfg(
       mergeWhenPipelineSucceeds: Boolean
   )
-
-  def from(args: Cli.Args): Config =
-    Config(
-      workspace = args.workspace,
-      reposFile = args.reposFile,
-      gitCfg = GitCfg(
-        gitAuthor = Author(args.gitAuthorName, args.gitAuthorEmail, args.gitAuthorSigningKey),
-        gitAskPass = args.gitAskPass,
-        signCommits = args.signCommits
-      ),
-      vcsCfg = VCSCfg(
-        tpe = args.vcsType,
-        apiHost = args.vcsApiHost,
-        login = args.vcsLogin,
-        doNotFork = args.doNotFork
-      ),
-      ignoreOptsFiles = args.ignoreOptsFiles,
-      processCfg = ProcessCfg(
-        envVars = args.envVar,
-        processTimeout = args.processTimeout,
-        maxBufferSize = args.maxBufferSize,
-        sandboxCfg = SandboxCfg(
-          whitelistedDirectories = args.whitelist,
-          readOnlyDirectories = args.readOnly,
-          enableSandbox = args.enableSandbox.getOrElse(!args.disableSandbox)
-        )
-      ),
-      repoConfigCfg = RepoConfigCfg(
-        repoConfigs = args.repoConfig,
-        disableDefault = args.disableDefaultRepoConfig
-      ),
-      scalafixCfg = ScalafixCfg(
-        migrations = args.scalafixMigrations,
-        disableDefaults = args.disableDefaultScalafixMigrations
-      ),
-      artifactCfg = ArtifactCfg(
-        migrations = args.artifactMigrations,
-        disableDefaults = args.disableDefaultArtifactMigrations
-      ),
-      cacheTtl = args.cacheTtl,
-      bitbucketServerCfg = BitbucketServerCfg(
-        useDefaultReviewers = args.bitbucketServerUseDefaultReviewers
-      ),
-      gitLabCfg = GitLabCfg(
-        mergeWhenPipelineSucceeds = args.gitlabMergeWhenPipelineSucceeds
-      ),
-      githubApp = Apply[Option].map2(args.githubAppId, args.githubAppKeyFile)(GitHubApp.apply),
-      urlCheckerTestUrl = args.urlCheckerTestUrl.getOrElse(uri"https://github.com"),
-      defaultResolver = args.defaultMavenRepo
-        .map(url => Resolver.MavenRepository("default", url, None))
-        .getOrElse(Resolver.mavenCentral),
-      refreshBackoffPeriod = args.refreshBackoffPeriod
-    )
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
index 10db8ed3..03addff9 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
@@ -76,11 +76,10 @@ final class Context[F[_]](implicit
 )
 
 object Context {
-  def step0[F[_]](args: Cli.Args)(implicit F: Async[F]): Resource[F, Context[F]] =
+  def step0[F[_]](config: Config)(implicit F: Async[F]): Resource[F, Context[F]] =
     for {
       logger <- Resource.eval(Slf4jLogger.fromName[F]("org.scalasteward.core"))
       _ <- Resource.eval(printBanner(logger))
-      config = Config.from(args)
       _ <- Resource.eval(F.delay(System.setProperty("http.agent", userAgentString)))
       userAgent <- Resource.eval(F.fromEither(`User-Agent`.parse(userAgentString)))
       userAgentMiddleware = ClientConfiguration.setUserAgent[F](userAgent)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/util/dateTime.scala b/modules/core/src/main/scala/org/scalasteward/core/util/dateTime.scala
index 595f2035..8ca32726 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/util/dateTime.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/util/dateTime.scala
@@ -28,6 +28,9 @@ object dateTime {
       case d                  => Left(new Throwable(s"$d is not a FiniteDuration"))
     }
 
+  def renderFiniteDuration(fd: FiniteDuration): String =
+    fd.toString.filterNot(_.isSpaceChar)
+
   def showDuration(d: FiniteDuration): String = {
     def symbol(unit: TimeUnit): String =
       unit match {
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala
index e92c3f9a..98e70587 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala
@@ -22,7 +22,7 @@ import org.http4s.syntax.literals._
 import org.scalasteward.core.util.unexpectedString
 import org.scalasteward.core.vcs.VCSType._
 
-sealed trait VCSType {
+sealed trait VCSType extends Product with Serializable {
   def publicWebHost: Option[String]
 
   val asString: String = this match {
diff --git a/modules/docs/mdoc/help.md b/modules/docs/mdoc/help.md
index 77487ce4..9fd88f04 100644
--- a/modules/docs/mdoc/help.md
+++ b/modules/docs/mdoc/help.md
@@ -3,9 +3,8 @@
 All command line arguments for the `scala-steward` application.
 
 ```scala mdoc:passthrough
-import caseapp.core.help._
-import org.scalasteward.core.application.Cli._
+import org.scalasteward.core.application.Cli
 println("```")
-println(Help[Args].help(HelpFormat.default(false)))
+println(Cli.command.showHelp)
 println("```")
 ```
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 5ddab86e..eea55d05 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -5,7 +5,6 @@ import sbt.librarymanagement.syntax.ExclusionRule
 object Dependencies {
   val bcprovJdk15to18 = "org.bouncycastle" % "bcprov-jdk15to18" % "1.70"
   val betterFiles = "com.github.pathikrit" %% "better-files" % "3.9.1"
-  val caseApp = "com.github.alexarchambault" %% "case-app" % "2.1.0-M10"
   val catsEffect = "org.typelevel" %% "cats-effect" % "3.3.1"
   val catsCore = "org.typelevel" %% "cats-core" % "2.7.0"
   val catsLaws = "org.typelevel" %% "cats-laws" % catsCore.revision
@@ -19,6 +18,7 @@ object Dependencies {
   val commonsIo = "commons-io" % "commons-io" % "2.11.0"
   val coursierCore = "io.get-coursier" %% "coursier" % "2.0.16"
   val cron4sCore = "com.github.alonsodomin.cron4s" %% "cron4s-core" % "0.6.1"
+  val decline = "com.monovore" %% "decline" % "2.1.0"
   val disciplineMunit = "org.typelevel" %% "discipline-munit" % "1.0.9"
   val fs2Core = "co.fs2" %% "fs2-core" % "3.2.3"
   val fs2Io = "co.fs2" %% "fs2-io" % fs2Core.revision
diff --git a/project/build.properties b/project/build.properties
index baf5ff3e..e64c208f 100644
--- a/project/build.properties
+++ b/project/build.properties
@@ -1,1 +1,1 @@
-sbt.version=1.5.7
+sbt.version=1.5.8


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala b/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala
index a1dd5614..a61cb485 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala
@@ -1,16 +1,18 @@
 package org.scalasteward.core.application
 
 import better.files.File
+import cats.data.Validated.Valid
 import munit.FunSuite
 import org.http4s.syntax.literals._
 import org.scalasteward.core.application.Cli.EnvVar
 import org.scalasteward.core.application.Cli.ParseResult._
 import org.scalasteward.core.vcs.VCSType
+import org.scalasteward.core.vcs.github.GitHubApp
 import scala.concurrent.duration._
 
 class CliTest extends FunSuite {
   test("parseArgs: example") {
-    val obtained = Cli.parseArgs(
+    val Success(obtained) = Cli.parseArgs(
       List(
         List("--workspace", "a"),
         List("--repos-file", "b"),
@@ -23,7 +25,7 @@ class CliTest extends FunSuite {
         List("--env-var", "g=h"),
         List("--env-var", "i=j"),
         List("--process-timeout", "30min"),
-        List("--max-buffer-size", "8192"),
+        List("--max-buffer-size", "1234"),
         List("--scalafix-migrations", "/opt/scala-steward/extra-scalafix-migrations.conf"),
         List("--artifact-migrations", "/opt/scala-steward/extra-artifact-migrations.conf"),
         List("--repo-config", "/opt/scala-steward/scala-steward.conf"),
@@ -32,32 +34,36 @@ class CliTest extends FunSuite {
         List("--refresh-backoff-period", "1 day")
       ).flatten
     )
-    val expected = Success(
-      Cli.Args(
-        workspace = File("a"),
-        reposFile = File("b"),
-        gitAuthorEmail = "d",
-        vcsType = VCSType.GitLab,
-        vcsApiHost = uri"http://example.com",
-        vcsLogin = "e",
-        gitAskPass = File("f"),
-        ignoreOptsFiles = true,
-        envVar = List(EnvVar("g", "h"), EnvVar("i", "j")),
-        processTimeout = 30.minutes,
-        maxBufferSize = 8192,
-        scalafixMigrations = List(uri"/opt/scala-steward/extra-scalafix-migrations.conf"),
-        artifactMigrations = List(uri"/opt/scala-steward/extra-artifact-migrations.conf"),
-        repoConfig = List(uri"/opt/scala-steward/scala-steward.conf"),
-        githubAppId = Some(12345678),
-        githubAppKeyFile = Some(File("example_app_key")),
-        refreshBackoffPeriod = 1.day
-      )
+
+    assertEquals(obtained.workspace, File("a"))
+    assertEquals(obtained.reposFile, File("b"))
+    assertEquals(obtained.gitCfg.gitAuthor.email, "d")
+    assertEquals(obtained.gitCfg.gitAskPass, File("f"))
+    assertEquals(obtained.vcsCfg.tpe, VCSType.GitLab)
+    assertEquals(obtained.vcsCfg.apiHost, uri"http://example.com")
+    assertEquals(obtained.vcsCfg.login, "e")
+    assertEquals(obtained.ignoreOptsFiles, true)
+    assertEquals(obtained.processCfg.envVars, List(EnvVar("g", "h"), EnvVar("i", "j")))
+    assertEquals(obtained.processCfg.processTimeout, 30.minutes)
+    assertEquals(obtained.processCfg.maxBufferSize, 1234)
+    assertEquals(
+      obtained.repoConfigCfg.repoConfigs,
+      List(uri"/opt/scala-steward/scala-steward.conf")
+    )
+    assertEquals(
+      obtained.scalafixCfg.migrations,
+      List(uri"/opt/scala-steward/extra-scalafix-migrations.conf")
+    )
+    assertEquals(
+      obtained.artifactCfg.migrations,
+      List(uri"/opt/scala-steward/extra-artifact-migrations.conf")
     )
-    assertEquals(obtained, expected)
+    assertEquals(obtained.githubApp, Some(GitHubApp(12345678L, File("example_app_key"))))
+    assertEquals(obtained.refreshBackoffPeriod, 1.day)
   }
 
   test("parseArgs: minimal example") {
-    val obtained = Cli.parseArgs(
+    val Success(obtained) = Cli.parseArgs(
       List(
         List("--workspace", "a"),
         List("--repos-file", "b"),
@@ -66,69 +72,39 @@ class CliTest extends FunSuite {
         List("--git-ask-pass", "f")
       ).flatten
     )
-    val expected = Success(
-      Cli.Args(
-        workspace = File("a"),
-        reposFile = File("b"),
-        gitAuthorEmail = "d",
-        vcsLogin = "e",
-        gitAskPass = File("f")
-      )
-    )
-    assertEquals(obtained, expected)
+
+    assertEquals(obtained.workspace, File("a"))
+    assertEquals(obtained.reposFile, File("b"))
+    assertEquals(obtained.gitCfg.gitAuthor.email, "d")
+    assertEquals(obtained.gitCfg.gitAskPass, File("f"))
+    assertEquals(obtained.vcsCfg.login, "e")
   }
 
   test("parseArgs: fail if required option not provided") {
-    assert(clue(Cli.parseArgs(Nil).asInstanceOf[Error].error).startsWith("Required option"))
+    val Error(obtained) = Cli.parseArgs(Nil)
+    assert(clue(obtained).startsWith("Missing expected"))
   }
 
   test("parseArgs: unrecognized argument") {
-    assert(clue(Cli.parseArgs(List("--foo")).asInstanceOf[Error].error).startsWith("Unrecognized"))
+    val Error(obtained) = Cli.parseArgs(List("--foo"))
+    assert(clue(obtained).startsWith("Unexpected option"))
   }
 
   test("parseArgs: --help") {
-    assert(
-      clue(Cli.parseArgs(List("--help")).asInstanceOf[Help].help).contains("--git-author-email")
-    )
+    val Help(obtained) = Cli.parseArgs(List("--help"))
+    assert(clue(obtained).startsWith("Usage"))
   }
 
-  test("parseArgs: --usage") {
-    assert(
-      clue(Cli.parseArgs(List("--usage")).asInstanceOf[Help].help)
-        .startsWith("Usage: \u001b[1margs\u001b[0m")
-    )
+  test("envVarArgument: env-var without equals sign") {
+    assert(clue(Cli.envVarArgument.read("SBT_OPTS")).isInvalid)
   }
 
-  test("envVarArgParser: env-var without equals sign") {
-    assert(clue(Cli.envVarArgParser(None, "SBT_OPTS")).isLeft)
-  }
-
-  test("envVarArgParser: env-var with multiple equals signs") {
+  test("envVarArgument: env-var with multiple equals signs") {
     val value = "-Xss8m -XX:MaxMetaspaceSize=256m"
-    assertEquals(Cli.envVarArgParser(None, s"SBT_OPTS=$value"), Right(EnvVar("SBT_OPTS", value)))
-  }
-
-  test("finiteDurationArgParser: well-formed duration") {
-    assertEquals(Cli.finiteDurationArgParser(None, "30min"), Right(30.minutes))
-  }
-
-  test("finiteDurationArgParser: malformed duration") {
-    assert(clue(Cli.finiteDurationArgParser(None, "xyz")).isLeft)
-  }
-
-  test("finiteDurationArgParser: malformed duration (Inf)") {
-    assert(clue(Cli.finiteDurationArgParser(None, "Inf")).isLeft)
-  }
-
-  test("finiteDurationArgParser: previous value") {
-    assert(clue(Cli.finiteDurationArgParser(Some(10.seconds), "20seconds")).isLeft)
-  }
-
-  test("fileArgParser: previous value") {
-    assert(clue(Cli.fileArgParser(Some(File("/tmp")), "/opt")).isLeft)
+    assertEquals(Cli.envVarArgument.read(s"SBT_OPTS=$value"), Valid(EnvVar("SBT_OPTS", value)))
   }
 
-  test("vcsTypeArgParser: unknown value") {
-    assert(clue(Cli.vcsTypeArgParser(None, "sourceforge")).isLeft)
+  test("vcsTypeArgument: unknown value") {
+    assert(clue(Cli.vcsTypeArgument.read("sourceforge")).isInvalid)
   }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala b/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala
index fae5bfd8..6ea8bd1a 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala
@@ -1,30 +1,27 @@
 package org.scalasteward.core.mock
 
 import better.files.File
-import org.http4s.Uri
-import org.scalasteward.core.application.Cli.EnvVar
+import org.scalasteward.core.application.Cli.ParseResult.Success
 import org.scalasteward.core.application.{Cli, Config}
 import org.scalasteward.core.git.FileGitAlg
-import org.scalasteward.core.vcs.VCSType
-import scala.concurrent.duration._
 
 object MockConfig {
   val mockRoot: File = File.temp / "scala-steward"
-  val args: Cli.Args = Cli.Args(
-    workspace = mockRoot / "workspace",
-    reposFile = mockRoot / "repos.md",
-    gitAuthorName = "Bot Doe",
-    gitAuthorEmail = "bot@example.org",
-    vcsType = VCSType.GitHub,
-    vcsApiHost = Uri(),
-    vcsLogin = "bot-doe",
-    gitAskPass = mockRoot / "askpass.sh",
-    enableSandbox = Some(true),
-    envVar = List(EnvVar("VAR1", "val1"), EnvVar("VAR2", "val2")),
-    cacheTtl = 1.hour,
-    refreshBackoffPeriod = 1.hour
+  private val args: List[String] = List(
+    s"--workspace=$mockRoot/workspace",
+    s"--repos-file=$mockRoot/repos.md",
+    "--git-author-name=Bot Doe",
+    "--git-author-email=bot@example.org",
+    s"--git-ask-pass=$mockRoot/askpass.sh",
+    "--vcs-api-host=http://example.com",
+    "--vcs-login=bot-doe",
+    "--enable-sandbox",
+    "--env-var=VAR1=val1",
+    "--env-var=VAR2=val2",
+    "--cache-ttl=1hour",
+    "--refresh-backoff-period=1hour"
   )
-  val config: Config = Config.from(args)
+  val Success(config: Config) = Cli.parseArgs(args)
   val envVars = List(s"GIT_ASKPASS=${config.gitCfg.gitAskPass}", "VAR1=val1", "VAR2=val2")
   def gitCmd(repoDir: File): List[String] =
     envVars ++ (repoDir.toString :: FileGitAlg.gitCmd.toList)
diff --git a/modules/core/src/test/scala/org/scalasteward/core/util/dateTimeTest.scala b/modules/core/src/test/scala/org/scalasteward/core/util/dateTimeTest.scala
index e4f5cdcf..d312f440 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/util/dateTimeTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/util/dateTimeTest.scala
@@ -13,7 +13,7 @@ class dateTimeTest extends ScalaCheckSuite {
   }
 
   def parseFiniteDurationRoundTrips(fd: FiniteDuration): Unit =
-    parseFiniteDuration(fd.toString).fold(t => throw t, approxEq(_, fd))
+    parseFiniteDuration(renderFiniteDuration(fd)).fold(t => throw t, approxEq(_, fd))
 
   property("parseFiniteDuration") {
     forAll((d: FiniteDuration) => parseFiniteDurationRoundTrips(d))
@@ -23,6 +23,10 @@ class dateTimeTest extends ScalaCheckSuite {
     parseFiniteDurationRoundTrips(-6340054257704093L.microseconds)
   }
 
+  test("parseFiniteDuration: invalid input") {
+    assert(clue(parseFiniteDuration("Inf").isLeft))
+  }
+
   test("showDuration: example 1") {
     val d = 2.days + 20.hours + 37.minutes + 3.seconds + 586.millis + 491.micros + 264.nanos
     assertEquals(showDuration(d), "2d 20h 37m 3s 586ms 491µs 264ns")