# 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)