Install

Most people don’t need this page — the home page covers the one-step paths for agents, VS Code, and the CLI. This page is the full reference: every install surface, requirements, and CI recipes.

CLI

The compose-preview binary works against any Compose project with no build edits — it injects the preview plugin at runtime via a bundled Gradle init script.

curl -fsSL https://raw.githubusercontent.com/yschimke/skills/main/scripts/install.sh | bash

The installer drops the CLI on your $PATH and (unless you pass --cli-only) the compose-preview / compose-preview-review agent skills into the Claude / Codex / Gemini skill directories. Useful flags:

Flag Effect
--cli-only Install the CLI only, skip the skill bundles.
--android-sdk Also install the Android cmdline-tools + platform + build-tools.
--jdk 17,21 Install the listed JDK majors.
VERSION Install a specific release instead of latest.

Then:

compose-preview doctor    # check Java + project compatibility
compose-preview list      # scan @Preview annotations
compose-preview render    # render every @Preview to PNG
compose-preview browse    # Storybook-like local component browser
compose-preview render-matrix --id com.example.MyPreview \
    --ui-mode light,dark --font-scale 1.0,2.0   # one preview across a grid

browse scans every valid preview module. When a conventional executable CMP Wasm browser project owns the previews or depends on their module, its distribution is built and attached automatically:

compose-preview browse

Java/Android-only composables remain available as rendered snapshots with their sample source. --module :shared:ui is an optional narrowing control, not required setup.

Because the CLI auto-injects, projects that already apply com.android.application / com.android.library / org.jetbrains.compose work without touching build.gradle.kts. Projects that already declare the plugin are detected and left alone, so mixed setups don’t conflict.

Pinning one version for every entrypoint

compose-preview reaches a project through several doors — the CLI, the VS Code extension, the CI actions, an explicitly applied Gradle plugin — and by default each one uses whatever version it was installed at. On a project with more than one door in use that drifts, and a CLI newer than the plugin it’s driving fails in a way that reads like a project misconfiguration (“no modules have the compose-preview plugin applied”) rather than the version skew it is.

Pin the project once:

compose-preview pin --cli      # pin to the CLI you just installed
compose-preview pin 1.1.0      # …or to a specific release
compose-preview pin            # show the pin, and whether your CLI matches

That writes a single line to your gradle.properties:

composePreview.version=1.1.0

From then on the CLI auto-injects that plugin version, the VS Code extension applies it too, and CI installs it with version: pin on the install action (the apply action’s default cli-version: auto picks it up with no workflow change). If a project already pins composePreviewCli in gradle/libs.versions.toml, that’s read as a pin too — nothing to add.

compose-preview doctor reports the pin, its source, and whether the CLI on your $PATH matches it. Unpinned projects keep working exactly as before; the pin is opt-in.

The pin governs the auto-injected plugin — the zero-config path above. A module that declares id("ee.schimke.composeai.preview") version "…" itself keeps its own version, and compose-preview pin never edits build scripts or version catalogs: auto-inject already leaves such a module alone, so its declaration is the single source of truth there. Full model: VERSION_PIN.md.

Builds that apply AGP via a convention plugin

If your build supplies the Android Gradle Plugin through an included build’s convention plugin — the build-logic / gradle/conventions pattern used by Now in Android, AndroidX, and similar repos — auto-inject cannot apply the preview plugin, and discovery will report 0 modules with failures like:

NoClassDefFoundError: com/android/build/api/variant/AndroidComponentsExtension

This is a classloader limitation, not a bug. With the convention-plugin layout AGP is applied by the included build, so AGP’s classes live on the convention plugin’s classloader. Auto-inject can only add the preview plugin to each project’s own buildscript classpath — a sibling classloader that can’t see AGP — so the plugin throws the moment it touches AndroidComponentsExtension. There is no Gradle init-script API to contribute a dependency to an included build’s classpath, so auto-inject genuinely can’t reach AGP here.

Apply the plugin from your convention plugin instead. That puts it on the same classloader as AGP, where it renders correctly. The CLI then detects the included-build apply and skips auto-inject automatically (no --no-auto-inject needed).

  1. Add the preview plugin’s marker artifact to your build-logic build’s dependencies. This is the key step: it puts the plugin on the convention build’s runtime classpath, so the compiled convention plugin can apply it by id. (Declaring it in build-logic’s plugins {} block with apply false only resolves it for that build script — not for the convention plugin applied to your app modules, which fails with Plugin with id … not found.) The marker coordinate is <id>:<id>.gradle.plugin:<version>:

    // build-logic/.../build.gradle.kts (the convention build)
    plugins {
        `kotlin-dsl`
    }
    dependencies {
        implementation(
            "ee.schimke.composeai.preview:ee.schimke.composeai.preview.gradle.plugin:<latest>"
        )
    }
    

    Make sure build-logic’s repositories include where the plugin is published (mavenCentral() / gradlePluginPortal()).

  2. Apply it from your convention plugin alongside AGP:

    // build-logic/.../YourAndroidConventionPlugin.kt
    override fun apply(target: Project) = with(target) {
        pluginManager.apply("com.android.library") // or the AGP plugin you use
        pluginManager.apply("ee.schimke.composeai.preview")
        // …rest of your convention…
    }
    
  3. Run the CLI as usual — it sees the included build provides the plugin and leaves your build alone:

    compose-preview render
    

This is the explicit “apply manually via your convention plugin” integration mode: auto-inject is the zero-config convenience for the common plugins { id("com.android.application") } layout; the convention-plugin layout opts out of it by design.

Gradle plugin (version-pinned)

If you’d rather wire it into your build explicitly, the plugin is published to Maven Central — no auth, no PAT.

// <module>/build.gradle.kts
plugins {
    id("ee.schimke.composeai.preview") version "<latest>"
}

Pin the version shown on Maven Central. (The in-repo samples/ apply it without a version because they resolve it from this repository’s own included build — that’s not drop-in for an external project.)

./gradlew :app:composePreviewDiscover    # scan @Preview annotations
./gradlew :app:composePreviewRenderAll   # render every @Preview to PNG

For direct ./gradlew use with the auto-inject script (e.g. a CI step that needs extra Gradle flags), materialise the init script once and thread its path through each invocation:

INIT_SCRIPT="$(compose-preview init-script --path)"
./gradlew --init-script "$INIT_SCRIPT" :app:composePreviewRenderAll

VS Code extension

Published to the VS Code Marketplace and Open VSX (VSCodium / Cursor / Windsurf). Open the Extensions view (⇧⌘X / Ctrl+Shift+X), search Compose Preview, click Install. It auto-injects on every Gradle invocation it makes — no project changes needed.

Agent skill

Point any agent that can fetch a URL at the compose-preview skill — a complete install-and-iterate playbook. The skill checks whether the CLI is present and bootstraps it (via the installer above) if not, so “point the agent at the skill” and “run the installer” converge on the same place. See Agents & MCP for the agent loop the skill drives.

CI / GitHub Actions

Composite actions for pipelines:

  • install — pin the CLI on $PATH. version: pin reads the project’s own version pin; version: catalog tracks a specific catalog key (Renovate recipe included).
  • apply — unified pipeline: baselines on push, before/after PR comments, a11y + notification surfaces.

There’s also a reusable, preview-gated AI PR-review workflow (Codex / Claude / Gemini): see PR review workflow.

Speeding up preview CI

Rendering is the long pole. Two levers, both about parallelism:

  • Sharding (on by default). composePreview { shards } fans a module’s previews across parallel JVM forks. The default is 0 (auto): the plugin sizes the fork count from the discovered preview cost and the runner’s cores
    • memory, and only shards when the predicted saving is worth the extra JVM cold-starts — so small modules stay single-fork and heavy ones (many previews, GIF/animated captures) fan out automatically. Set shards = 1 to force it off, or a fixed ≥ 2 to pin it. No workflow changes needed; auto sizing engages on the first CI run because the pipeline discovers previews in a separate Gradle pass before rendering.
  • Bigger runners. Auto sharding is capped at cores − 1 forks (leaving one core for the Gradle daemon), so it can only go as wide as the runner is. The default runs-on: ubuntu-latest is 2 vCPU on private repos (no sharding) and 4 vCPU on public ones (up to 3 forks). For a large preview suite, point the preview job at a 4- or 8-vCPU runner — that’s where the two levers compound into real wall-clock savings. RAM scales with cores on GitHub-hosted runners, which keeps the per-fork memory bound out of the way.

Render classpath conflicts

The render JVM classpath is built from a single resolved dependency graph: the plugin’s renderer configuration extendsFrom your module’s unit-test runtime classpath (and its screenshotTest runtime classpath, when Google’s screenshot plugin is applied), so Gradle picks one version per module across the renderer’s dependencies and your own. Only entries that exist nowhere else — the unit-test merged R.jar, generated class dirs — are appended from AGP’s test task on top.

Before this, those graphs were resolved separately and concatenated, which put two versions of the same module in front of one classloader. Java loads the first match per class, so a class from the winning jar could link against a sibling only the other version defined, and you’d get a NoSuchFieldError or NoSuchMethodError in an unrelated <clinit> — e.g. two bcprov-jdk18on jars failing every accessibility preview inside BouncyCastle’s post-quantum KeyFactorySpi.

A guard runs before each render and reports any module still present at more than one version, naming each version and the jar that wins:

Property Default Effect
composePreview.classpathDuplicates warn fail turns a duplicate into a build error (good for CI); off silences the check.
composePreview.legacyClasspathUnion false true restores the old concatenated classpath. Escape hatch only — set it if a render suddenly can’t find a class that lives solely on your unit-test classpath, and please file an issue.

If the guard reports a duplicate module, align it in your own build with a version force, or exclude it from whichever graph shouldn’t carry it.

Split families

A second check covers libraries published as several coordinates with no BOMorg.bouncycastle:bcprov/bcutil/bcpkix is the usual offender. Gradle aligns a module against itself and nothing further, so the graph can settle on bcprov-jdk18on:1.85 while leaving bcutil-jdk18on:1.84: one version per coordinate, no conflict to resolve, and classes that still don’t agree at runtime. That’s what failed every accessibility preview in homeassistant-remotecompose#495.

The fix is a virtual platform, which states that the coordinates move together and lets normal conflict resolution pick the highest version any member asks for:

abstract class BouncyCastleAlignmentRule : ComponentMetadataRule {
  override fun execute(context: ComponentMetadataContext) {
    val id = context.details.id
    if (id.group == "org.bouncycastle") {
      context.details.belongsTo("org.bouncycastle:bouncycastle-virtual-platform:${id.version}")
    }
  }
}

dependencies { components.all(BouncyCastleAlignmentRule::class.java) }

Prefer that over useVersion("1.84"): a force goes stale, and silently becomes a downgrade the moment one member’s floor moves (Robolectric’s did).

The plugin reports this rather than applying it. ComponentMetadataRules register on DependencyHandler, so they apply to every configuration in the module — including your releaseRuntimeClasspath. Aligning a family upward is usually right for a render classpath and isn’t a preview tool’s call to make for a shipped app, so the decision stays yours.

Requirements

Java 17+, Gradle 8.13+, AGP 8.13.0+ (Android), Kotlin 2.0.21+, Compose Multiplatform 1.10.3+ (Desktop). The bottom edge of this envelope is exercised on every push by the agp8-min job.

Running an agent in a cloud sandbox (Claude Code on the web, etc.)? See the cloud sandbox setup for the network allowlist and install.sh --android-sdk Setup recipe. </content>


Apache 2.0 licensed. Source on GitHub.

This site uses Just the Docs, a documentation theme for Jekyll.