diff --git a/.planning/phases/05-cli-scaffolding/05-RESEARCH.md b/.planning/phases/05-cli-scaffolding/05-RESEARCH.md new file mode 100644 index 0000000..0052c5b --- /dev/null +++ b/.planning/phases/05-cli-scaffolding/05-RESEARCH.md @@ -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)