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
This commit is contained in:
Jakub Zych
2026-02-05 14:15:41 +01:00
parent 44685de65e
commit 0d4a96fee2

View File

@@ -0,0 +1,975 @@
# 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):**
```scala
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
### Recommended CLI Module Structure
```
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:**
```scala
// 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:**
```scala
// 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:**
```scala
// 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:**
```scala
// 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:**
```scala
// 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:**
```scala
// 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:**
```scala
// 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:**
```scala
// 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:**
```scala
// 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:**
```scala
// 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
```scala
// 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
```scala
// 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)
```scala
// 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)
- [ZIO CLI Documentation](https://zio.dev/zio-cli/) - Commands, options, wizard mode, shell completion
- [ZIO CLI GitHub](https://github.com/zio/zio-cli) - Version 0.7.4, API reference
- [os-lib GitHub](https://github.com/com-lihaoyi/os-lib) - Version 0.11.7, file operations
- [fansi GitHub](https://github.com/com-lihaoyi/fansi) - Version 0.5.1, terminal colors
- [Mill Bundled Libraries](https://mill-build.org/mill/fundamentals/bundled-libraries.html) - os-lib, MainArgs integration
- [WinterCMS Scaffolding Commands](https://wintercms.com/docs/v1.2/docs/console/scaffolding) - Reference patterns
### Secondary (MEDIUM confidence)
- [MainArgs GitHub](https://github.com/com-lihaoyi/mainargs) - Version 0.7.7, alternative CLI parser
- [JLine 3 GitHub](https://github.com/jline/jline3) - Version 4.0.0, advanced terminal input
- [ZIO CLI Built-in Commands](https://zio.dev/zio-cli/built-in-commands/) - Wizard and help features
### 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)