Files
Jakub Zych 0d4a96fee2 docs(05): research CLI scaffolding domain
Phase 5: CLI Scaffolding
- Standard stack: ZIO CLI 0.7.4, os-lib 0.11.7, fansi 0.5.1
- Architecture patterns for noun-verb commands documented
- Template rendering with string interpolation
- File operations with os-lib
- Interactive wizard mode via ZIO CLI built-in
- Common pitfalls catalogued
2026-02-05 14:15:41 +01:00

37 KiB

Phase 5: CLI Scaffolding - Research

Researched: 2026-02-05 Domain: CLI parsing, file generation, interactive prompts, terminal output Confidence: HIGH

Summary

This phase implements the summer CLI tool for scaffolding plugins, themes, components, controllers, models, and migrations. The research covers CLI parsing libraries for Scala 3/ZIO, file system operations, template-based code generation, interactive prompts, and terminal UI (colors, progress bars).

The recommended stack uses: ZIO CLI 0.7.4 for command parsing (native ZIO integration, built-in wizard mode, shell completion), os-lib 0.11.7 for file operations (bundled with Mill, simple API, path handling), fansi 0.5.1 for colored terminal output, and string interpolation for template generation (simple scaffolds don't need template engine). Interactive prompts use ZIO CLI's built-in wizard mode with fallback to JLine for advanced cases.

Primary recommendation: Use ZIO CLI for all command parsing with noun-verb subcommand structure, os-lib for file generation with os.makeDir.all and os.write, string interpolation for templates with placeholder replacement, and fansi for colored output with --no-color fallback.

Standard Stack

The established libraries/tools for this domain:

Core

Library Version Purpose Why Standard
ZIO CLI 0.7.4 Command parsing Native ZIO, wizard mode, shell completion, type-safe
os-lib 0.11.7 File operations Bundled with Mill, simple API, cross-platform paths
fansi 0.5.1 Colored output Zero-dependency, Scala 3 native, efficient

Supporting

Library Version Purpose When to Use
JLine 4.0.0 Advanced prompts Complex interactive input beyond wizard mode
circe-yaml 0.16.1 YAML generation Generating plugin.yaml manifests

Alternatives Considered

Instead of Could Use Tradeoff
ZIO CLI MainArgs MainArgs simpler but no wizard mode, shell completion
ZIO CLI Decline Decline uses cats, needs interop-cats for ZIO
os-lib java.nio.file nio verbose, os-lib has better ergonomics
fansi scala-rainbow scala-rainbow less maintained, fansi more features
String interpolation Pebble Overkill for scaffolds, adds dependency

Installation (Mill build.mill):

def mvnDeps = Seq(
  // Existing deps from Phase 1-4...

  // CLI parsing
  mvn"dev.zio::zio-cli:0.7.4",

  // File operations (bundled with Mill, but explicit for CLI module)
  mvn"com.lihaoyi::os-lib:0.11.7",

  // Colored terminal output
  mvn"com.lihaoyi::fansi:0.5.1"
)

Architecture Patterns

cli/
├── src/
│   ├── Main.scala              # CLI entry point, CliApp
│   ├── commands/
│   │   ├── PluginCommands.scala    # summer plugin create/list
│   │   ├── ThemeCommands.scala     # summer theme create/list
│   │   ├── ComponentCommands.scala # summer component create
│   │   ├── ControllerCommands.scala # summer controller create
│   │   ├── ModelCommands.scala     # summer model create
│   │   ├── MigrationCommands.scala # summer migration create/run/status
│   │   └── VersionCommand.scala    # summer version
│   ├── scaffold/
│   │   ├── ScaffoldService.scala   # File generation service
│   │   ├── TemplateRenderer.scala  # Variable substitution
│   │   └── templates/
│   │       ├── plugin/
│   │       ├── theme/
│   │       ├── component/
│   │       ├── controller/
│   │       └── model/
│   ├── config/
│   │   └── ConfigDetector.scala    # Find project root, load config
│   └── output/
│       ├── Console.scala           # Colored output service
│       └── Progress.scala          # Progress bar service

Pattern 1: ZIO CLI Command Structure (Noun-Verb)

What: Commands follow noun-verb pattern with subcommands When to use: All CLI commands Example:

// Source: ZIO CLI documentation + CONTEXT.md decisions
import zio.cli.*

// Command models
sealed trait CliCommand
object CliCommand:
  case class PluginCreate(name: String, dryRun: Boolean) extends CliCommand
  case class ThemeCreate(name: String, template: String, dryRun: Boolean) extends CliCommand
  case class ComponentCreate(plugin: String, name: String, dryRun: Boolean) extends CliCommand
  case class ModelCreate(plugin: String, name: String, noMigration: Boolean, dryRun: Boolean) extends CliCommand
  case class MigrationCreate(plugin: String, name: Option[String], dryRun: Boolean) extends CliCommand
  case class Version() extends CliCommand

// Options
val dryRunOption: Options[Boolean] =
  Options.boolean("dry-run").alias("n")
    .withHelp("Show what would be created without creating")

val templateOption: Options[String] =
  Options.text("template").withDefault("starter")
    .withHelp("Theme template: blank or starter")

// Arguments
val pluginNameArg: Args[String] =
  Args.text("name")
    .withHelp("Plugin name in Author.Name format (e.g., Golem15.Blog)")

// Subcommands
val pluginCreate: Command[CliCommand.PluginCreate] =
  Command("create", dryRunOption, pluginNameArg)
    .withHelp("Create a new plugin scaffold")
    .map((dryRun, name) => CliCommand.PluginCreate(name, dryRun))

val pluginCommand: Command[CliCommand] =
  Command("plugin")
    .withHelp("Plugin management commands")
    .subcommands(pluginCreate)

// Top-level command combining all subcommands
val summerCommand: Command[CliCommand] =
  Command("summer")
    .subcommands(
      pluginCommand,
      themeCommand,
      componentCommand,
      controllerCommand,
      modelCommand,
      migrationCommand,
      versionCommand
    )

Pattern 2: CLI Application Entry Point

What: Main entry using ZIOCliDefault with CliApp.make When to use: CLI main class Example:

// Source: ZIO CLI documentation
import zio.*
import zio.cli.*

object Main extends ZIOCliDefault:
  val cliApp = CliApp.make(
    name = "summer",
    version = BuildInfo.version,
    summary = HelpDoc.p("SummerCMS scaffolding CLI"),
    command = summerCommand
  ) { command =>
    command match
      case CliCommand.PluginCreate(name, dryRun) =>
        PluginScaffold.create(name, dryRun)
      case CliCommand.ThemeCreate(name, template, dryRun) =>
        ThemeScaffold.create(name, template, dryRun)
      case CliCommand.Version() =>
        printVersion
      // ... other commands
  }.provideSome[ZIOAppArgs](
    ScaffoldService.live,
    ConsoleOutput.live,
    ConfigDetector.live
  )

  def printVersion: ZIO[Any, Nothing, Unit] =
    Console.printLine(s"SummerCMS ${BuildInfo.version} (Scala ${BuildInfo.scalaVersion}, JVM ${System.getProperty("java.version")})")
      .orDie

Pattern 3: Scaffold Service with os-lib

What: Service for file generation using os-lib operations When to use: All file creation operations Example:

// Source: os-lib documentation
import zio.*

trait ScaffoldService:
  def createDirectory(path: os.Path): IO[ScaffoldError, Unit]
  def writeFile(path: os.Path, content: String): IO[ScaffoldError, Unit]
  def exists(path: os.Path): UIO[Boolean]
  def listFiles(path: os.Path): IO[ScaffoldError, List[os.Path]]

object ScaffoldService:
  val live: ZLayer[Any, Nothing, ScaffoldService] =
    ZLayer.succeed {
      new ScaffoldService:
        def createDirectory(path: os.Path): IO[ScaffoldError, Unit] =
          ZIO.attemptBlocking {
            os.makeDir.all(path)
          }.mapError(e => ScaffoldError.FileSystemError(path.toString, e))

        def writeFile(path: os.Path, content: String): IO[ScaffoldError, Unit] =
          ZIO.attemptBlocking {
            // Create parent directories if needed
            os.makeDir.all(path / os.up)
            os.write(path, content)
          }.mapError(e => ScaffoldError.FileSystemError(path.toString, e))

        def exists(path: os.Path): UIO[Boolean] =
          ZIO.succeed(os.exists(path))

        def listFiles(path: os.Path): IO[ScaffoldError, List[os.Path]] =
          ZIO.attemptBlocking {
            os.list(path).toList
          }.mapError(e => ScaffoldError.FileSystemError(path.toString, e))
    }

Pattern 4: Template Rendering with String Interpolation

What: Simple variable substitution in template strings When to use: Scaffold file generation Example:

// Source: WinterCMS BaseScaffoldCommand pattern adapted to Scala
case class TemplateVars(
  name: String,
  author: String,
  plugin: String
):
  // Derived variables
  val lowerName: String = name.toLowerCase
  val lowerAuthor: String = author.toLowerCase
  val lowerPlugin: String = plugin.toLowerCase
  val studlyName: String = name.split("[_\\-\\s]").map(_.capitalize).mkString
  val studlyAuthor: String = author.capitalize
  val studlyPlugin: String = plugin.capitalize
  val snakeName: String = name.replaceAll("([A-Z])", "_$1").toLowerCase.stripPrefix("_")
  val snakePluralName: String = pluralize(snakeName)
  val titleName: String = name.split("[_\\-\\s]").map(_.capitalize).mkString(" ")
  val pluginId: String = s"$lowerAuthor.$lowerPlugin"
  val pluginCode: String = s"$studlyAuthor.$studlyPlugin"
  val pluginNamespace: String = s"$studlyAuthor.$studlyPlugin"
  val tableName: String = s"${lowerAuthor}_${lowerPlugin}_${snakePluralName}"
  val timestamp: String = java.time.LocalDateTime.now()
    .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))

trait TemplateRenderer:
  def render(template: String, vars: TemplateVars): String

object TemplateRenderer:
  val live: ZLayer[Any, Nothing, TemplateRenderer] =
    ZLayer.succeed {
      new TemplateRenderer:
        def render(template: String, vars: TemplateVars): String =
          template
            .replace("{{name}}", vars.name)
            .replace("{{lower_name}}", vars.lowerName)
            .replace("{{studly_name}}", vars.studlyName)
            .replace("{{snake_name}}", vars.snakeName)
            .replace("{{author}}", vars.author)
            .replace("{{lower_author}}", vars.lowerAuthor)
            .replace("{{studly_author}}", vars.studlyAuthor)
            .replace("{{plugin}}", vars.plugin)
            .replace("{{lower_plugin}}", vars.lowerPlugin)
            .replace("{{studly_plugin}}", vars.studlyPlugin)
            .replace("{{plugin_id}}", vars.pluginId)
            .replace("{{plugin_namespace}}", vars.pluginNamespace)
            .replace("{{table_name}}", vars.tableName)
            .replace("{{timestamp}}", vars.timestamp)
    }

Pattern 5: Plugin Scaffold Generation

What: Complete plugin scaffold with proper structure When to use: summer plugin create Author.Name Example:

// Source: WinterCMS CreatePlugin pattern + CONTEXT.md decisions
object PluginScaffold:
  def create(nameArg: String, dryRun: Boolean): ZIO[ScaffoldService & ConsoleOutput, ScaffoldError, Unit] =
    for
      // 1. Parse Author.Name format
      (author, name) <- ZIO.fromEither(parsePluginName(nameArg))
        .mapError(ScaffoldError.InvalidName(_))

      // 2. Build paths
      pluginsDir = os.pwd / "plugins"
      pluginDir = pluginsDir / author.toLowerCase / name.toLowerCase

      // 3. Check if exists
      scaffold <- ZIO.service[ScaffoldService]
      console  <- ZIO.service[ConsoleOutput]
      exists   <- scaffold.exists(pluginDir)
      _        <- ZIO.when(exists) {
        ZIO.fail(ScaffoldError.AlreadyExists(pluginDir.toString))
      }

      // 4. Build template vars
      vars = TemplateVars(name, author, name)

      // 5. Define files to create
      files = List(
        (pluginDir / "Plugin.scala", pluginTemplate),
        (pluginDir / "plugin.yaml", manifestTemplate),
        (pluginDir / "routes.scala", routesTemplate),
        (pluginDir / "models" / ".gitkeep", ""),
        (pluginDir / "controllers" / ".gitkeep", ""),
        (pluginDir / "components" / ".gitkeep", ""),
        (pluginDir / "resources" / "db" / "migration" / ".gitkeep", ""),
        (pluginDir / "resources" / "lang" / "en" / "lang.yaml", langTemplate),
        (pluginDir / "resources" / "views" / ".gitkeep", "")
      )

      // 6. Render and create (or show dry-run)
      renderer = TemplateRenderer.live
      _ <- ZIO.foreach(files) { case (path, template) =>
        val content = renderer.render(template, vars)
        if dryRun then
          console.info(s"Would create: $path")
        else
          scaffold.writeFile(path, content) *>
          console.success(s"Created: $path")
      }

      // 7. Show next steps
      _ <- console.nextSteps(List(
        s"Edit Plugin.scala to configure your plugin",
        s"Add models with: summer model create ${vars.pluginCode} ModelName",
        s"Add components with: summer component create ${vars.pluginCode} ComponentName",
        s"Run migrations with: summer migration run"
      ))
    yield ()

  private def parsePluginName(name: String): Either[String, (String, String)] =
    name.split("\\.").toList match
      case List(author, plugin) if author.nonEmpty && plugin.nonEmpty =>
        Right((author, plugin))
      case _ =>
        Left(s"Invalid plugin name '$name'. Use Author.Name format (e.g., Golem15.Blog)")

  private val pluginTemplate = """
    |package {{plugin_namespace}}
    |
    |import com.summercms.plugin.*
    |import zio.*
    |
    |// TODO: Configure your plugin here
    |class Plugin extends SummerPlugin:
    |  def id: PluginId = PluginId("{{lower_author}}", "{{lower_plugin}}")
    |
    |  def register(ctx: PluginContext): PluginRegistration =
    |    PluginRegistration(
    |      components = List.empty,
    |      permissions = List.empty,
    |      navigation = List.empty,
    |      settings = List.empty,
    |      events = List.empty,
    |      extensions = List.empty
    |    )
    |
    |  def boot: ZIO[PluginEnv, PluginError, Unit] =
    |    ZIO.unit
    |
    |  def shutdown: ZIO[Any, Nothing, Unit] =
    |    ZIO.unit
    |""".stripMargin

  private val manifestTemplate = """
    |vendor: {{lower_author}}
    |name: {{lower_plugin}}
    |version: 1.0.0
    |description: "TODO: Add description"
    |author: {{studly_author}}
    |
    |dependencies: {}
    |
    |enabled: true
    |""".stripMargin

Pattern 6: Colored Console Output with fansi

What: Terminal output service with colors and styling When to use: All CLI output Example:

// Source: fansi documentation
import fansi.*

trait ConsoleOutput:
  def info(message: String): UIO[Unit]
  def success(message: String): UIO[Unit]
  def warning(message: String): UIO[Unit]
  def error(message: String): UIO[Unit]
  def nextSteps(steps: List[String]): UIO[Unit]

object ConsoleOutput:
  def live(useColors: Boolean = true): ZLayer[Any, Nothing, ConsoleOutput] =
    ZLayer.succeed {
      new ConsoleOutput:
        private def colorize(str: Str): String =
          if useColors then str.render else str.plainText

        def info(message: String): UIO[Unit] =
          Console.printLine(colorize(Color.Cyan(message))).orDie

        def success(message: String): UIO[Unit] =
          Console.printLine(colorize(Color.Green("[OK] ") ++ Str(message))).orDie

        def warning(message: String): UIO[Unit] =
          Console.printLine(colorize(Color.Yellow("[WARN] ") ++ Str(message))).orDie

        def error(message: String): UIO[Unit] =
          Console.printLine(colorize(Color.Red("[ERROR] ") ++ Str(message))).orDie

        def nextSteps(steps: List[String]): UIO[Unit] =
          val header = colorize(Bold.On("Next steps:"))
          val bulletPoints = steps.map(s => s"  - $s").mkString("\n")
          Console.printLine(s"\n$header\n$bulletPoints\n").orDie
    }

Pattern 7: Migration Timestamp Naming

What: Migrations named with timestamp prefix for ordering When to use: summer migration create command Example:

// Source: CONTEXT.md decisions
object MigrationScaffold:
  def generateMigrationName(description: String): String =
    val timestamp = java.time.LocalDateTime.now()
      .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
    val snakeName = description
      .replaceAll("([A-Z])", "_$1")
      .toLowerCase
      .stripPrefix("_")
      .replaceAll("\\s+", "_")
    s"${timestamp}_$snakeName"

  // e.g., "Create Posts Table" -> "20260205_143022_create_posts_table.scala"

  def create(plugin: String, nameOpt: Option[String], dryRun: Boolean): ZIO[ScaffoldEnv, ScaffoldError, Unit] =
    for
      (author, pluginName) <- ZIO.fromEither(parsePluginName(plugin))
      name = nameOpt.getOrElse("migration")
      migrationName = generateMigrationName(name)
      migrationDir = os.pwd / "plugins" / author.toLowerCase / pluginName.toLowerCase / "resources" / "db" / "migration"

      vars = TemplateVars(name, author, pluginName)
      content = migrationTemplate.render(vars)

      scaffold <- ZIO.service[ScaffoldService]
      console  <- ZIO.service[ConsoleOutput]

      _ <- if dryRun then
        console.info(s"Would create: $migrationDir/${migrationName}.scala")
      else
        scaffold.writeFile(migrationDir / s"${migrationName}.scala", content) *>
        console.success(s"Created: $migrationDir/${migrationName}.scala")
    yield ()

Pattern 8: Interactive Wizard Mode

What: ZIO CLI's built-in wizard prompts for missing arguments When to use: Interactive mode when args missing or --wizard flag Example:

// Source: ZIO CLI built-in commands documentation
// Wizard mode is automatic in ZIO CLI with --wizard flag

// If user runs: summer plugin create --wizard
// ZIO CLI will prompt:
//   > Select command: [plugin, theme, component, ...]
//   > Enter plugin name:
//   > Enable dry-run? [y/N]

// For custom prompts beyond wizard (e.g., validation loops):
object InteractivePrompt:
  def promptWithValidation(
    prompt: String,
    validate: String => Either[String, String]
  ): ZIO[Any, Nothing, String] =
    def loop: ZIO[Any, Nothing, String] =
      Console.print(prompt).orDie *>
      Console.readLine.orDie.flatMap { input =>
        validate(input) match
          case Right(valid) => ZIO.succeed(valid)
          case Left(error) =>
            Console.printLine(fansi.Color.Red(error).render).orDie *>
            loop
      }
    loop

  // Usage for plugin name validation:
  def promptPluginName: ZIO[Any, Nothing, String] =
    promptWithValidation(
      "Plugin name (Author.Name): ",
      input => parsePluginName(input).map(_._1 + "." + _._2)
    )

Pattern 9: Config File Detection

What: Find SummerCMS project root by looking for marker files When to use: CLI startup to determine paths Example:

// Source: Common CLI pattern (CONTEXT.md Claude's discretion)
trait ConfigDetector:
  def findProjectRoot: IO[ScaffoldError, os.Path]
  def loadConfig: IO[ScaffoldError, ProjectConfig]

object ConfigDetector:
  // Marker files that indicate SummerCMS root
  private val markers = List(
    "build.mill",
    "build.mill.yaml",
    ".summercms"
  )

  val live: ZLayer[Any, Nothing, ConfigDetector] =
    ZLayer.succeed {
      new ConfigDetector:
        def findProjectRoot: IO[ScaffoldError, os.Path] =
          ZIO.attemptBlocking {
            var current = os.pwd
            while (current != os.root) {
              if (markers.exists(m => os.exists(current / m)))
                return ZIO.succeed(current)
              current = current / os.up
            }
            throw new Exception("Not in a SummerCMS project")
          }.flatMap(identity)
           .mapError(e => ScaffoldError.NotInProject(e.getMessage))

        def loadConfig: IO[ScaffoldError, ProjectConfig] =
          for
            root <- findProjectRoot
            configPath = root / ".summercms" / "config.yaml"
            exists <- ZIO.succeed(os.exists(configPath))
            config <- if exists then
              ZIO.attemptBlocking(os.read(configPath))
                .flatMap(parseConfig)
                .mapError(e => ScaffoldError.ConfigError(e.getMessage))
            else
              ZIO.succeed(ProjectConfig.default)
          yield config
    }

Pattern 10: Progress Bar for Larger Operations

What: Simple progress indication for multi-file scaffolds When to use: Theme scaffold with Tailwind, any operation with multiple steps Example:

// Source: Common CLI pattern (CONTEXT.md Claude's discretion)
trait Progress:
  def start(total: Int, description: String): UIO[ProgressHandle]
  def update(handle: ProgressHandle, current: Int): UIO[Unit]
  def complete(handle: ProgressHandle): UIO[Unit]

case class ProgressHandle(id: Long, total: Int, description: String)

object Progress:
  val live: ZLayer[Any, Nothing, Progress] =
    ZLayer.succeed {
      new Progress:
        private val counter = new java.util.concurrent.atomic.AtomicLong(0)

        def start(total: Int, description: String): UIO[ProgressHandle] =
          ZIO.succeed {
            val handle = ProgressHandle(counter.incrementAndGet(), total, description)
            print(s"$description [${progressBar(0, total)}] 0/$total\r")
            handle
          }

        def update(handle: ProgressHandle, current: Int): UIO[Unit] =
          ZIO.succeed {
            print(s"${handle.description} [${progressBar(current, handle.total)}] $current/${handle.total}\r")
          }

        def complete(handle: ProgressHandle): UIO[Unit] =
          ZIO.succeed {
            println(s"${handle.description} [${progressBar(handle.total, handle.total)}] ${handle.total}/${handle.total}")
          }

        private def progressBar(current: Int, total: Int): String =
          val width = 30
          val filled = (current.toDouble / total * width).toInt
          val empty = width - filled
          "=" * filled + " " * empty
    }

Anti-Patterns to Avoid

  • Direct System.out.println: Use ConsoleOutput service for testability and color support
  • Blocking file I/O without ZIO: Wrap all os-lib calls in ZIO.attemptBlocking
  • Hard-coded paths: Use ConfigDetector to find project root
  • Mutable state in scaffolds: Use ZIO Ref if state needed between steps
  • Ignoring --dry-run: Every file operation must check dry-run flag
  • Silent failures: Always report errors with clear messages and suggestions
  • Requiring interactive input in CI: Check --no-interaction flag

Don't Hand-Roll

Problems that look simple but have existing solutions:

Problem Don't Build Use Instead Why
Command parsing Regex on args ZIO CLI Type safety, help gen, completion, wizard
File system ops java.io.File os-lib Cross-platform, ergonomic, path safety
Terminal colors ANSI escape codes fansi Handles terminal detection, efficient
Progress bars Custom spinners Simple print loop Sufficient for scaffolding use case
YAML generation String concat circe-yaml Proper escaping, structure
Template rendering Custom parser String interpolation Scaffolds are simple, no need for Pebble
Input validation Manual loops ZIO CLI validators Composable, type-safe

Key insight: CLI scaffolding is IO-heavy but logic-light. Use proven libraries for parsing and file operations; keep template generation simple with string interpolation.

Common Pitfalls

Pitfall 1: Path Separator Issues on Windows

What goes wrong: Paths broken on Windows due to hardcoded / Why it happens: Using string concatenation instead of path combinators How to avoid: Always use os.Path with / operator: root / "plugins" / author Warning signs: "File not found" errors only on Windows

Pitfall 2: Existing File Overwrite

What goes wrong: User's existing code overwritten silently Why it happens: No existence check before write How to avoid: Check os.exists() before write, fail with clear message (no --force per CONTEXT.md) Warning signs: Lost user code, angry developers

Pitfall 3: Invalid Plugin Name Format

What goes wrong: Directory structure wrong, plugin won't load Why it happens: Name not validated (missing dot, special characters) How to avoid: Validate format upfront: ^[A-Z][a-zA-Z0-9]*\\.[A-Z][a-zA-Z0-9]*$ Warning signs: Plugin discovery failures, namespace errors

Pitfall 4: Missing Parent Directories

What goes wrong: File write fails with "directory not found" Why it happens: Trying to write file before creating parent dirs How to avoid: Use os.makeDir.all(path / os.up) before os.write Warning signs: "No such file or directory" errors

Pitfall 5: CI Environment Interactive Prompts

What goes wrong: Build hangs waiting for input Why it happens: Interactive prompt in non-interactive environment How to avoid: Check --no-interaction flag, fail with clear error on missing required args Warning signs: CI timeouts, hanging builds

Pitfall 6: Wizard Mode Not Triggering

What goes wrong: User expects prompts but gets error Why it happens: ZIO CLI wizard requires explicit --wizard flag How to avoid: Document wizard mode clearly, consider making it default for missing args Warning signs: Confused users, support requests

Pitfall 7: Color Output in Non-TTY

What goes wrong: ANSI codes visible as garbage characters Why it happens: Colors output to redirected stdout or file How to avoid: fansi handles this, also support --no-color flag Warning signs: Garbled output in logs, CI output

Pitfall 8: Template Variable Collision

What goes wrong: Template contains literal {{name}} that shouldn't be replaced Why it happens: Content and variables use same syntax How to avoid: Use distinctive delimiters or escape sequence for literal braces Warning signs: Unexpected content replacement

Code Examples

Verified patterns from official sources:

Complete CLI Main Entry

// Source: ZIO CLI documentation
package com.summercms.cli

import zio.*
import zio.cli.*

object Main extends ZIOCliDefault:
  // Global options
  val noColorOption: Options[Boolean] =
    Options.boolean("no-color")
      .withHelp("Disable colored output")

  val verboseOption: Options[Boolean] =
    Options.boolean("verbose").alias("v")
      .withHelp("Enable verbose output")

  val quietOption: Options[Boolean] =
    Options.boolean("quiet").alias("q")
      .withHelp("Suppress non-essential output")

  val noInteractionOption: Options[Boolean] =
    Options.boolean("no-interaction")
      .withHelp("Disable interactive prompts (for CI)")

  // Build complete command tree
  val command: Command[CliCommand] =
    Command("summer", noColorOption ++ verboseOption ++ quietOption ++ noInteractionOption)
      .subcommands(
        PluginCommands.command,
        ThemeCommands.command,
        ComponentCommands.command,
        ControllerCommands.command,
        ModelCommands.command,
        MigrationCommands.command,
        VersionCommand.command
      )

  val cliApp: CliApp[CliCommand] = CliApp.make(
    name = "summer",
    version = BuildInfo.version,
    summary = HelpDoc.p("SummerCMS scaffolding and development CLI"),
    command = command
  ) { case ((noColor, verbose, quiet, noInteraction), cmd) =>
    val consoleLayer = ConsoleOutput.live(!noColor)
    val configLayer = ConfigDetector.live
    val scaffoldLayer = ScaffoldService.live

    cmd match
      case CliCommand.PluginCreate(name, dryRun) =>
        PluginScaffold.create(name, dryRun)
          .provide(consoleLayer, configLayer, scaffoldLayer)

      case CliCommand.Version() =>
        Console.printLine(
          s"SummerCMS ${BuildInfo.version} (Scala ${BuildInfo.scalaVersion}, JVM ${System.getProperty("java.version")})"
        )

      // ... other commands
  }

Theme Scaffold with Template Choice

// Source: WinterCMS CreateTheme pattern + CONTEXT.md decisions
object ThemeScaffold:
  sealed trait ThemeTemplate
  case object Blank extends ThemeTemplate
  case object Starter extends ThemeTemplate

  def create(
    name: String,
    templateArg: String,
    dryRun: Boolean
  ): ZIO[ScaffoldEnv, ScaffoldError, Unit] =
    for
      // 1. Parse template choice
      template <- ZIO.fromEither(
        templateArg.toLowerCase match
          case "blank" => Right(Blank)
          case "starter" => Right(Starter)
          case other => Left(ScaffoldError.InvalidTemplate(other))
      )

      // 2. Parse theme name (Author.ThemeName format)
      (author, themeName) <- ZIO.fromEither(parseThemeName(name))

      // 3. Build paths
      config   <- ZIO.service[ConfigDetector].flatMap(_.loadConfig)
      themeDir = config.themesDir / author.toLowerCase / themeName.toLowerCase

      // 4. Check existence
      scaffold <- ZIO.service[ScaffoldService]
      exists   <- scaffold.exists(themeDir)
      _        <- ZIO.when(exists) {
        ZIO.fail(ScaffoldError.AlreadyExists(themeDir.toString))
      }

      // 5. Generate files based on template
      vars = TemplateVars(themeName, author, themeName)
      files = template match
        case Blank => blankThemeFiles(themeDir, vars)
        case Starter => starterThemeFiles(themeDir, vars)

      // 6. Create with progress
      console  <- ZIO.service[ConsoleOutput]
      progress <- ZIO.service[Progress]

      _ <- if dryRun then
        ZIO.foreach(files) { case (path, _) =>
          console.info(s"Would create: $path")
        }
      else
        for
          handle <- progress.start(files.length, "Creating theme")
          _ <- ZIO.foreachDiscard(files.zipWithIndex) { case ((path, content), idx) =>
            scaffold.writeFile(path, content) *>
            progress.update(handle, idx + 1)
          }
          _ <- progress.complete(handle)
        yield ()

      // 7. Next steps
      _ <- console.nextSteps(List(
        s"Edit theme.yaml to configure your theme",
        template match
          case Starter =>
            s"Run 'cd themes/${author.toLowerCase}/${themeName.toLowerCase} && npm install' to install dependencies"
          case Blank =>
            "Add layouts, pages, and partials to your theme"
      ))
    yield ()

  private def blankThemeFiles(dir: os.Path, vars: TemplateVars): List[(os.Path, String)] =
    List(
      (dir / "theme.yaml", themeManifest(vars)),
      (dir / "layouts" / "default.htm", blankLayout),
      (dir / "pages" / "home.htm", blankHomePage),
      (dir / "pages" / "404.htm", blank404Page),
      (dir / "partials" / ".gitkeep", ""),
      (dir / "assets" / "css" / ".gitkeep", ""),
      (dir / "assets" / "js" / ".gitkeep", "")
    )

  private def starterThemeFiles(dir: os.Path, vars: TemplateVars): List[(os.Path, String)] =
    blankThemeFiles(dir, vars) ++ List(
      (dir / "package.json", packageJson(vars)),
      (dir / "vite.config.js", viteConfig),
      (dir / ".gitignore", themeGitignore),
      // Tailwind starter files
      (dir / "assets" / "css" / "app.css", tailwindCss),
      (dir / "assets" / "js" / "app.js", starterJs)
    )

Component Scaffold (Plugin Required)

// Source: WinterCMS CreateComponent pattern + CONTEXT.md decisions
object ComponentScaffold:
  def create(
    pluginArg: String,
    componentName: String,
    dryRun: Boolean
  ): ZIO[ScaffoldEnv, ScaffoldError, Unit] =
    for
      // Component requires explicit plugin
      (author, plugin) <- ZIO.fromEither(parsePluginName(pluginArg))

      // Verify plugin exists
      config    <- ZIO.service[ConfigDetector].flatMap(_.loadConfig)
      pluginDir = config.pluginsDir / author.toLowerCase / plugin.toLowerCase
      scaffold  <- ZIO.service[ScaffoldService]
      exists    <- scaffold.exists(pluginDir / "Plugin.scala")
      _         <- ZIO.unless(exists) {
        ZIO.fail(ScaffoldError.PluginNotFound(pluginArg))
      }

      // Build paths and vars
      componentDir = pluginDir / "components"
      vars = TemplateVars(componentName, author, plugin)

      files = List(
        (componentDir / s"${vars.studlyName}.scala", componentClass(vars)),
        (componentDir / vars.lowerName / "default.htm", componentPartial(vars)),
        (componentDir / vars.lowerName / "component.yaml", componentConfig(vars))
      )

      console <- ZIO.service[ConsoleOutput]
      _ <- ZIO.foreach(files) { case (path, content) =>
        val rendered = TemplateRenderer.live.render(content, vars)
        if dryRun then
          console.info(s"Would create: $path")
        else
          scaffold.writeFile(path, rendered) *>
          console.success(s"Created: $path")
      }

      _ <- console.nextSteps(List(
        s"Register component in Plugin.scala register() method",
        s"Edit ${vars.studlyName}.scala to add component logic",
        s"Edit default.htm partial for component output"
      ))
    yield ()

  private def componentClass(vars: TemplateVars): String = s"""
    |package ${vars.pluginNamespace}.components
    |
    |import com.summercms.component.*
    |import zio.*
    |
    |// TODO: Implement your component
    |class ${vars.studlyName} extends SummerComponent:
    |  def componentDetails: ComponentDetails =
    |    ComponentDetails(
    |      name = "${vars.titleName}",
    |      description = "TODO: Add description"
    |    )
    |
    |  def defineProperties: List[PropertyDef] =
    |    List.empty
    |
    |  def onRun: ZIO[ComponentEnv, ComponentError, Map[String, Any]] =
    |    ZIO.succeed(Map.empty)
    |""".stripMargin

State of the Art

Old Approach Current Approach When Changed Impact
Scopt for CLI parsing ZIO CLI 2023+ ZIO-native, wizard mode, shell completion
java.io.File os-lib 2020+ Cross-platform, better API, safer paths
Manual ANSI codes fansi 2018+ Terminal detection, efficient, composable
Scalate for templates String interpolation Always for simple No dependency, fast, debuggable
Interactive readline JLine/ZIO CLI wizard JLine 4.x Menu selection, history, completion

Deprecated/outdated:

  • scopt: Less maintained, no wizard mode
  • scallop: Macro-based, compile-time issues with Scala 3
  • java.io.File: Not cross-platform, verbose API
  • Manual ANSI escape codes: Brittle, terminal-specific

Open Questions

Things that couldn't be fully resolved:

  1. Shell Completion Installation

    • What we know: ZIO CLI generates completion scripts via --shell-completion-script
    • What's unclear: Best UX for installing completions (manual copy vs auto-install command)
    • Recommendation: Add summer completion install command that writes to appropriate shell config
  2. Native Binary Distribution

    • What we know: Mill can create fat JARs; GraalVM native-image possible but complex
    • What's unclear: Whether startup time warrants native compilation
    • Recommendation: Start with fat JAR via Mill assembly; evaluate native-image later based on user feedback
  3. Plugin Templates Beyond Basic

    • What we know: WinterCMS has options like --controller, --all on model create
    • What's unclear: Full set of template combinations needed
    • Recommendation: Start with basic scaffolds, add templates based on common patterns observed
  4. BuildInfo Generation

    • What we know: Need version/Scala info in CLI output
    • What's unclear: Best Mill plugin for BuildInfo generation
    • Recommendation: Use mill-buildinfo or hand-roll with Mill's T.ctx() for simple cases
  5. Wizard Mode Default Behavior

    • What we know: ZIO CLI wizard is opt-in with --wizard flag
    • What's unclear: Should wizard be default when required args missing vs error?
    • Recommendation: Per CONTEXT.md, prompt interactively when required args missing (make wizard default)

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • Community blog posts on ZIO CLI usage
  • Stack Overflow answers on CLI patterns

Metadata

Confidence breakdown:

  • Standard stack: HIGH - ZIO CLI, os-lib, fansi are well-documented with official sources
  • Command structure: HIGH - Based on ZIO CLI docs and CONTEXT.md locked decisions
  • File operations: HIGH - os-lib API is well-documented, Mill integration verified
  • Template rendering: HIGH - Simple string interpolation, WinterCMS patterns verified
  • Interactive prompts: MEDIUM - ZIO CLI wizard documented, JLine for advanced cases
  • Progress bar: MEDIUM - Custom implementation, simple approach sufficient

Research date: 2026-02-05 Valid until: 2026-03-05 (30 days - CLI libraries are stable)