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
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
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:
// 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-interactionflag
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:
-
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 installcommand that writes to appropriate shell config
- What we know: ZIO CLI generates completion scripts via
-
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
-
Plugin Templates Beyond Basic
- What we know: WinterCMS has options like
--controller,--allon model create - What's unclear: Full set of template combinations needed
- Recommendation: Start with basic scaffolds, add templates based on common patterns observed
- What we know: WinterCMS has options like
-
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
-
Wizard Mode Default Behavior
- What we know: ZIO CLI wizard is opt-in with
--wizardflag - 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)
- What we know: ZIO CLI wizard is opt-in with
Sources
Primary (HIGH confidence)
- ZIO CLI Documentation - Commands, options, wizard mode, shell completion
- ZIO CLI GitHub - Version 0.7.4, API reference
- os-lib GitHub - Version 0.11.7, file operations
- fansi GitHub - Version 0.5.1, terminal colors
- Mill Bundled Libraries - os-lib, MainArgs integration
- WinterCMS Scaffolding Commands - Reference patterns
Secondary (MEDIUM confidence)
- MainArgs GitHub - Version 0.7.7, alternative CLI parser
- JLine 3 GitHub - Version 4.0.0, advanced terminal input
- 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)