--- phase: 02-plugin-system plan: 01 type: execute wave: 1 depends_on: [] files_modified: - build.mill - summercms/src/plugin/PluginId.scala - summercms/src/plugin/PluginManifest.scala - summercms/src/plugin/PluginError.scala - summercms/src/plugin/PluginDiscovery.scala - plugins/.gitkeep autonomous: true must_haves: truths: - "Developer can write a plugin.yaml with vendor, name, version, dependencies" - "System discovers all plugin.yaml files in plugins/ directory" - "Invalid YAML manifests produce clear error messages" - "Dependency version constraints parse correctly (^1.2.0, >=1.0.0)" artifacts: - path: "summercms/src/plugin/PluginManifest.scala" provides: "Plugin manifest case class with circe derivation" contains: "case class PluginManifest" - path: "summercms/src/plugin/PluginDiscovery.scala" provides: "Service to discover plugins from filesystem" exports: ["PluginDiscovery", "DiscoveredPlugin"] - path: "summercms/src/plugin/PluginId.scala" provides: "Plugin identifier (vendor.name)" contains: "case class PluginId" - path: "summercms/src/plugin/PluginError.scala" provides: "Plugin error ADT" contains: "enum PluginError" key_links: - from: "summercms/src/plugin/PluginDiscovery.scala" to: "summercms/src/plugin/PluginManifest.scala" via: "parseManifest" pattern: "parser\\.parse.*as\\[PluginManifest\\]" - from: "build.mill" to: "circe-yaml" via: "mvnDeps" pattern: "circe-yaml" --- Create the plugin discovery and manifest parsing foundation for SummerCMS. Purpose: All plugins need manifests parsed from YAML. Discovery finds plugins automatically. This is the foundation that lifecycle management (02-02) builds upon. Output: PluginManifest case class, PluginDiscovery service, PluginId/PluginError types, dependencies added to build.mill @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-plugin-system/02-RESEARCH.md @.planning/phases/02-plugin-system/02-CONTEXT.md @build.mill @summercms/src/Main.scala Task 1: Add YAML and version parsing dependencies build.mill Add the following dependencies to build.mill mvnDeps: ```scala // YAML parsing for plugin manifests mvn"io.circe::circe-yaml:0.16.1", mvn"io.circe::circe-generic:0.14.10", mvn"io.circe::circe-parser:0.14.10", // Semver version constraints mvn"com.github.sh4869::semver-parser-scala:0.0.4", ``` Add after the existing Flyway dependencies. Note: circe-parser is needed alongside circe-yaml for JSON operations. Run `./mill summercms.compile` - should succeed with new dependencies resolved build.mill contains circe-yaml, circe-generic, circe-parser, and semver-parser-scala dependencies Task 2: Create plugin type foundations summercms/src/plugin/PluginId.scala summercms/src/plugin/PluginError.scala Create `summercms/src/plugin/` directory with these files: **PluginId.scala:** ```scala package plugin /** Plugin identifier combining vendor and name */ case class PluginId(vendor: String, name: String): /** Full identifier string (vendor.name) */ def fullId: String = s"$vendor.$name" override def toString: String = fullId object PluginId: /** Parse "vendor.name" string into PluginId */ def parse(s: String): Either[String, PluginId] = s.split('.').toList match case vendor :: name :: Nil if vendor.nonEmpty && name.nonEmpty => Right(PluginId(vendor, name)) case _ => Left(s"Invalid plugin ID '$s': expected 'vendor.name' format") /** Unsafe parse - throws on invalid format */ def unsafeParse(s: String): PluginId = parse(s).getOrElse(throw new IllegalArgumentException(s"Invalid plugin ID: $s")) ``` **PluginError.scala:** ```scala package plugin import java.nio.file.Path /** Plugin system error ADT */ enum PluginError: case ManifestNotFound(path: Path) case ManifestParseError(path: Path, message: String) case InvalidPluginId(raw: String, reason: String) case DependencyNotFound(plugin: PluginId, dependency: String) case VersionMismatch(plugin: PluginId, dependency: String, required: String, actual: String) case CyclicDependency(plugins: Set[PluginId]) case BootError(plugin: PluginId, cause: Throwable) case ShutdownError(plugin: PluginId, cause: Throwable) def message: String = this match case ManifestNotFound(p) => s"Plugin manifest not found: $p" case ManifestParseError(p, m) => s"Failed to parse manifest at $p: $m" case InvalidPluginId(r, reason) => s"Invalid plugin ID '$r': $reason" case DependencyNotFound(p, d) => s"Plugin ${p.fullId} requires $d which is not installed" case VersionMismatch(p, d, req, act) => s"Plugin ${p.fullId} requires $d $req but found $act" case CyclicDependency(ps) => s"Cyclic dependency detected: ${ps.map(_.fullId).mkString(" -> ")}" case BootError(p, c) => s"Plugin ${p.fullId} failed to boot: ${c.getMessage}" case ShutdownError(p, c) => s"Plugin ${p.fullId} failed to shutdown: ${c.getMessage}" ``` Run `./mill summercms.compile` - plugin package compiles without errors PluginId case class with parse methods, PluginError ADT with all error cases Task 3: Create manifest parsing and discovery service summercms/src/plugin/PluginManifest.scala summercms/src/plugin/PluginDiscovery.scala plugins/.gitkeep **PluginManifest.scala:** ```scala package plugin import io.circe.* import io.circe.generic.auto.* import io.circe.yaml.parser import java.nio.file.Path /** Plugin manifest parsed from plugin.yaml */ case class PluginManifest( vendor: String, name: String, version: String, description: Option[String] = None, author: Option[String] = None, homepage: Option[String] = None, license: Option[String] = None, dependencies: Map[String, String] = Map.empty, // "vendor.name" -> "^1.2.0" optionalDependencies: Map[String, String] = Map.empty, priority: Int = 0, // Higher = boots first among peers enabled: Boolean = true ): /** Derived plugin ID */ def id: PluginId = PluginId(vendor, name) object PluginManifest: /** Parse YAML string into PluginManifest */ def parse(yaml: String): Either[PluginError, PluginManifest] = parser.parse(yaml) .flatMap(_.as[PluginManifest]) .left.map(e => PluginError.ManifestParseError( java.nio.file.Path.of("unknown"), e.getMessage )) /** Parse YAML file at path */ def parseFile(path: Path, content: String): Either[PluginError, PluginManifest] = parser.parse(content) .flatMap(_.as[PluginManifest]) .left.map(e => PluginError.ManifestParseError(path, e.getMessage)) ``` **PluginDiscovery.scala:** ```scala package plugin import zio.* import java.nio.file.{Files, Path, Paths} import scala.jdk.CollectionConverters.* /** A discovered plugin with its manifest and location */ case class DiscoveredPlugin( directory: Path, manifest: PluginManifest ): def id: PluginId = manifest.id /** Service for discovering plugins from filesystem */ trait PluginDiscovery: /** Discover all plugins in the given directory */ def discover(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]] object PluginDiscovery: /** Default plugins directory */ val defaultPluginsDir: Path = Paths.get("plugins") /** Access the discovery service */ def discover(pluginsDir: Path): ZIO[PluginDiscovery, PluginError, List[DiscoveredPlugin]] = ZIO.serviceWithZIO[PluginDiscovery](_.discover(pluginsDir)) /** Live implementation that scans filesystem */ val live: ULayer[PluginDiscovery] = ZLayer.succeed { new PluginDiscovery: def discover(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]] = for exists <- ZIO.attemptBlocking(Files.exists(pluginsDir)) .orDie plugins <- if !exists then ZIO.succeed(List.empty) else findAndParseManifests(pluginsDir) yield plugins private def findAndParseManifests(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]] = ZIO.attemptBlocking { // Walk up to 3 levels: plugins/vendor/name/plugin.yaml Files.walk(pluginsDir, 3).iterator.asScala .filter(p => p.getFileName.toString == "plugin.yaml") .toList }.orDie.flatMap { manifestPaths => ZIO.foreach(manifestPaths) { path => for content <- ZIO.attemptBlocking(Files.readString(path)) .mapError(_ => PluginError.ManifestNotFound(path)) manifest <- ZIO.fromEither(PluginManifest.parseFile(path, content)) yield DiscoveredPlugin(path.getParent, manifest) } } } ``` **Create plugins directory:** Create `plugins/.gitkeep` (empty file) to ensure the plugins directory exists in git. Run `./mill summercms.compile` - all plugin files compile. Create a test manifest at `plugins/test/sample/plugin.yaml`: ```yaml vendor: test name: sample version: 1.0.0 description: Test plugin dependencies: golem15.user: "^1.0.0" ``` Then verify parsing works by checking the code compiles and the types are correct. PluginManifest parses YAML manifests, PluginDiscovery scans plugins/ directory, plugins/ directory exists 1. `./mill summercms.compile` succeeds 2. Dependencies include circe-yaml:0.16.1, circe-generic:0.14.10, semver-parser-scala:0.0.4 3. Plugin directory structure exists: `plugins/` 4. All plugin types compile: PluginId, PluginError, PluginManifest, PluginDiscovery - build.mill has all new dependencies - PluginId.parse("golem15.blog") returns Right(PluginId("golem15", "blog")) - PluginManifest.parse(validYaml) returns Right(manifest) - PluginDiscovery.live provides working service - plugins/ directory exists with .gitkeep After completion, create `.planning/phases/02-plugin-system/02-01-SUMMARY.md`