Last active
April 7, 2026 09:26
-
-
Save ZacSweers/5106c32076db37d5e967a375dcf2c63f to your computer and use it in GitHub Desktop.
kmp-new.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # KMP Project Generator | |
| # Wraps https://github.com/Kotlin/kmp-wizard/ (AGP 9 templates) | |
| if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then | |
| cat <<'HELP' | |
| Usage: kmp-new [--help] | |
| Interactive CLI that generates a Kotlin Multiplatform project from | |
| https://github.com/Kotlin/kmp-wizard/ (AGP 9 templates). | |
| You will be prompted for: | |
| - Project name and package ID | |
| - Android minSdk | |
| - Gradle version (default 9.4.1) | |
| - Optional Metro (DI) and Circuit (navigation/presentation) integration | |
| - Template (mobile, all-frontends, or all-targets) | |
| - Output directory | |
| The generated project includes patches over the upstream templates: | |
| - AGP bumped to 9.1.0 | |
| - Gradle wrapper upgraded to chosen version (-bin dist) | |
| - kotlin-wrappers bumped to 2026.4.2 | |
| - Deprecated "androidLibrary {}" renamed to "android {}" | |
| HELP | |
| exit 0 | |
| fi | |
| DEFAULT_NAME="KotlinProject" | |
| DEFAULT_PACKAGE="org.example.project" | |
| REPO="https://github.com/Kotlin/kmp-wizard.git" | |
| # ── Helpers ────────────────────────────────────────────────────────── | |
| prompt() { | |
| local var="$1" prompt_text="$2" default="$3" | |
| printf "%s [%s]: " "$prompt_text" "$default" >&2 | |
| read -r input | |
| eval "$var=\"\${input:-$default}\"" | |
| } | |
| yesno() { | |
| local var="$1" prompt_text="$2" default="$3" | |
| printf "%s [%s]: " "$prompt_text" "$default" >&2 | |
| read -r input | |
| input="${input:-$default}" | |
| input="$(echo "$input" | tr '[:upper:]' '[:lower:]')" | |
| case "$input" in | |
| y|yes) eval "$var=true" ;; | |
| *) eval "$var=false" ;; | |
| esac | |
| } | |
| pick() { | |
| local var="$1" prompt_text="$2" | |
| shift 2 | |
| local options=("$@") | |
| echo "$prompt_text" >&2 | |
| for i in "${!options[@]}"; do | |
| echo " $((i+1))) ${options[$i]}" >&2 | |
| done | |
| while true; do | |
| printf "Choice [1]: " >&2 | |
| read -r choice | |
| choice="${choice:-1}" | |
| if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#options[@]} )); then | |
| eval "$var=\"\${options[$((choice-1))]}\"" | |
| return | |
| fi | |
| echo "Invalid choice, try again." >&2 | |
| done | |
| } | |
| # ── Gather inputs ──────────────────────────────────────────────────── | |
| echo "╭─────────────────────────────────╮" | |
| echo "│ KMP Project Generator (AGP9) │" | |
| echo "╰─────────────────────────────────╯" | |
| echo | |
| prompt PROJECT_NAME "Project name" "$DEFAULT_NAME" | |
| prompt PACKAGE_ID "Package ID" "$DEFAULT_PACKAGE" | |
| prompt MIN_SDK "Android minSdk" "24" | |
| prompt GRADLE_VERSION "Gradle version" "9.4.1" | |
| echo | |
| echo "Optional libraries:" | |
| yesno USE_METRO "Include Metro (DI)?" "n" | |
| yesno USE_CIRCUIT "Include Circuit (navigation/presentation)?" "n" | |
| yesno INIT_GIT "Initialize git repo?" "y" | |
| echo | |
| pick TEMPLATE "Select a template:" \ | |
| "mobile-shared — Android + iOS, Compose Multiplatform (shared UI)" \ | |
| "mobile-native — Android + iOS, SwiftUI for iOS (native UI)" \ | |
| "all-frontends-shared — Android + iOS + Desktop + Web, Compose Multiplatform" \ | |
| "all-frontends-native — Android + iOS + Desktop + Web, SwiftUI + React (native UIs)" \ | |
| "all-targets — Android + iOS + Desktop + Web + Server (Compose + Ktor)" | |
| # Extract the branch name (first word before the spaces/dash description) | |
| BRANCH="${TEMPLATE%% *}" | |
| echo | |
| prompt OUTPUT_DIR "Output directory" "./$PROJECT_NAME" | |
| # ── Validate ───────────────────────────────────────────────────────── | |
| if [[ -e "$OUTPUT_DIR" ]]; then | |
| echo "Error: '$OUTPUT_DIR' already exists." >&2 | |
| exit 1 | |
| fi | |
| if [[ ! "$PACKAGE_ID" =~ ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$ ]]; then | |
| echo "Warning: '$PACKAGE_ID' may not be a valid package ID." >&2 | |
| fi | |
| # ── Clone & customize ──────────────────────────────────────────────── | |
| echo | |
| echo "Cloning template '$BRANCH'..." | |
| git clone --single-branch --branch "$BRANCH" --depth 1 "$REPO" "$OUTPUT_DIR" 2>&1 | tail -1 | |
| # Remove upstream git history so it's a fresh project | |
| rm -rf "$OUTPUT_DIR/.git" | |
| echo "Customizing project..." | |
| # Rename package directories: org/example/project → user's package path | |
| OLD_PATH="org/example/project" | |
| NEW_PATH="$(echo "$PACKAGE_ID" | tr '.' '/')" | |
| if [[ "$OLD_PATH" != "$NEW_PATH" ]]; then | |
| # Find and move source directories | |
| while IFS= read -r -d '' dir; do | |
| # Figure out the prefix before the old package path | |
| prefix="${dir%$OLD_PATH}" | |
| new_dir="${prefix}${NEW_PATH}" | |
| mkdir -p "$new_dir" | |
| # Move contents rather than the dir itself to avoid nesting issues | |
| mv "$dir"/* "$new_dir"/ 2>/dev/null || true | |
| mv "$dir"/.* "$new_dir"/ 2>/dev/null || true | |
| # Remove the now-empty old package dirs (org/example/project) | |
| # Only remove dirs that are part of the old package, not above it | |
| rmdir "$dir" 2>/dev/null || true | |
| IFS='/' read -ra OLD_PARTS <<< "$OLD_PATH" | |
| for (( i=${#OLD_PARTS[@]}-2; i>=0; i-- )); do | |
| partial="${prefix}$(IFS=/; echo "${OLD_PARTS[*]:0:$((i+1))}")" | |
| rmdir "$partial" 2>/dev/null || true | |
| done | |
| done < <(find "$OUTPUT_DIR" -type d -path "*/$OLD_PATH" -print0) | |
| fi | |
| # Replace package name and project name in all text files | |
| replace_in_files() { | |
| local old="$1" new="$2" dir="$3" | |
| if [[ "$old" == "$new" ]]; then return; fi | |
| # Use grep to find files, then sed to replace (portable across macOS/Linux) | |
| grep -rl --include='*.kt' --include='*.kts' --include='*.xml' --include='*.toml' \ | |
| --include='*.properties' --include='*.json' --include='*.yaml' --include='*.yml' \ | |
| --include='*.swift' --include='*.plist' --include='*.xcconfig' --include='*.html' \ | |
| --include='*.js' --include='*.ts' --include='*.tsx' --include='*.css' \ | |
| "$old" "$dir" 2>/dev/null | while IFS= read -r file; do | |
| if [[ "$(uname)" == "Darwin" ]]; then | |
| sed -i '' "s|${old}|${new}|g" "$file" | |
| else | |
| sed -i "s|${old}|${new}|g" "$file" | |
| fi | |
| done | |
| } | |
| replace_in_files "org.example.project" "$PACKAGE_ID" "$OUTPUT_DIR" | |
| replace_in_files "$DEFAULT_NAME" "$PROJECT_NAME" "$OUTPUT_DIR" | |
| # Fix "import kotlinproject." → "import <lowercased project name>." | |
| LOWER_PROJECT="$(echo "$PROJECT_NAME" | tr '[:upper:]' '[:lower:]')" | |
| if [[ "kotlinproject" != "$LOWER_PROJECT" ]]; then | |
| replace_in_files "kotlinproject" "$LOWER_PROJECT" "$OUTPUT_DIR" | |
| fi | |
| # Rename the root settings project name if present | |
| SETTINGS_FILE="$OUTPUT_DIR/settings.gradle.kts" | |
| if [[ -f "$SETTINGS_FILE" ]]; then | |
| if [[ "$(uname)" == "Darwin" ]]; then | |
| sed -i '' "s|rootProject.name = \".*\"|rootProject.name = \"$PROJECT_NAME\"|g" "$SETTINGS_FILE" | |
| else | |
| sed -i "s|rootProject.name = \".*\"|rootProject.name = \"$PROJECT_NAME\"|g" "$SETTINGS_FILE" | |
| fi | |
| fi | |
| # ── Patches ────────────────────────────────────────────────────────── | |
| # The upstream kmp-wizard templates ship with older dependency versions | |
| # and some deprecated API usage. This section patches them to current. | |
| sed_inplace() { | |
| if [[ "$(uname)" == "Darwin" ]]; then | |
| sed -i '' "$@" | |
| else | |
| sed -i "$@" | |
| fi | |
| } | |
| LIBS_TOML="$OUTPUT_DIR/gradle/libs.versions.toml" | |
| if [[ -f "$LIBS_TOML" ]]; then | |
| # Bump AGP 9.0.1 → 9.1.0 | |
| sed_inplace 's|agp = "9.0.1"|agp = "9.1.0"|g' "$LIBS_TOML" | |
| # Bump kotlin-wrappers 2026.3.17 → 2026.4.2 | |
| sed_inplace 's|kotlin-wrappers = "2026.3.17"|kotlin-wrappers = "2026.4.2"|g' "$LIBS_TOML" | |
| # Apply user-specified minSdk | |
| sed_inplace "s|android-minSdk = \"24\"|android-minSdk = \"$MIN_SDK\"|g" "$LIBS_TOML" | |
| fi | |
| # Upgrade Gradle wrapper to user-specified version (default 9.4.1) | |
| WRAPPER_PROPS="$OUTPUT_DIR/gradle/wrapper/gradle-wrapper.properties" | |
| if [[ -f "$WRAPPER_PROPS" ]]; then | |
| sed_inplace "s|gradle-[0-9.]*-bin.zip|gradle-${GRADLE_VERSION}-bin.zip|g" "$WRAPPER_PROPS" | |
| # Remove stale sha256 — Gradle will re-download with the correct checksum | |
| sed_inplace '/distributionSha256Sum/d' "$WRAPPER_PROPS" | |
| fi | |
| # Fix deprecated "androidLibrary {" → "android {" in shared module(s) | |
| # The KMP Android plugin renamed this DSL block | |
| while IFS= read -r -d '' file; do | |
| sed_inplace 's|androidLibrary {|android {|g' "$file" | |
| done < <(find "$OUTPUT_DIR" -name "build.gradle.kts" -print0) | |
| # Fix unresolved reference 'bundleId' in iOS framework config | |
| # Need to explicitly set bundleId via freeCompilerArgs | |
| while IFS= read -r -d '' file; do | |
| if grep -q 'binaries.framework' "$file"; then | |
| sed_inplace "/isStatic = true/a\\ | |
| \\ freeCompilerArgs += \"-Xbinary=bundleId=${PACKAGE_ID}\" | |
| " "$file" | |
| fi | |
| done < <(find "$OUTPUT_DIR" -path "*/shared/build.gradle.kts" -print0) | |
| # ── Optional: Metro (DI) ──────────────────────────────────────────── | |
| # Adds the Metro Gradle plugin to the version catalog, root build file, | |
| # and shared module build file. | |
| if [[ "$USE_METRO" == "true" ]]; then | |
| echo "Adding Metro..." | |
| # Add version + plugin to libs.versions.toml | |
| sed_inplace '/^\[versions\]/a\ | |
| metro = "0.13.2" | |
| ' "$LIBS_TOML" | |
| sed_inplace '/^\[plugins\]/a\ | |
| metro = { id = "dev.zacsweers.metro", version.ref = "metro" } | |
| ' "$LIBS_TOML" | |
| # Add to root build.gradle.kts (apply false) | |
| ROOT_BUILD="$OUTPUT_DIR/build.gradle.kts" | |
| sed_inplace '/alias(libs.plugins.kotlinMultiplatform) apply false/a\ | |
| alias(libs.plugins.metro) apply false | |
| ' "$ROOT_BUILD" | |
| # Add to shared module build.gradle.kts (applied) | |
| while IFS= read -r -d '' file; do | |
| if grep -q 'alias(libs.plugins.composeCompiler)' "$file"; then | |
| sed_inplace '/alias(libs.plugins.composeCompiler)/a\ | |
| alias(libs.plugins.metro) | |
| ' "$file" | |
| fi | |
| done < <(find "$OUTPUT_DIR" -path "*/shared/build.gradle.kts" -print0) | |
| fi | |
| # ── Optional: Circuit (navigation/presentation) ───────────────────── | |
| # Adds circuit-foundation to the version catalog and shared module | |
| # commonMain dependencies. | |
| if [[ "$USE_CIRCUIT" == "true" ]]; then | |
| echo "Adding Circuit..." | |
| # Add version + libraries to libs.versions.toml | |
| sed_inplace '/^\[versions\]/a\ | |
| circuit = "0.33.1" | |
| ' "$LIBS_TOML" | |
| sed_inplace '/^\[libraries\]/a\ | |
| circuit-foundation = { module = "com.slack.circuit:circuit-foundation", version.ref = "circuit" }\ | |
| circuit-overlay = { module = "com.slack.circuit:circuit-overlay", version.ref = "circuit" }\ | |
| circuitx-overlays = { module = "com.slack.circuit:circuitx-overlays", version.ref = "circuit" }\ | |
| circuitx-gestureNav = { module = "com.slack.circuit:circuitx-gesture-navigation", version.ref = "circuit" } | |
| ' "$LIBS_TOML" | |
| # Add to shared module commonMain.dependencies | |
| while IFS= read -r -d '' file; do | |
| if grep -q 'commonMain.dependencies' "$file"; then | |
| sed_inplace '/commonMain.dependencies {/a\ | |
| implementation(libs.circuit.foundation) | |
| ' "$file" | |
| fi | |
| done < <(find "$OUTPUT_DIR" -path "*/shared/build.gradle.kts" -print0) | |
| fi | |
| # ── Optional: Metro + Circuit integration ──────────────────────────── | |
| # When both are enabled, tell Metro to generate Circuit factories. | |
| if [[ "$USE_METRO" == "true" && "$USE_CIRCUIT" == "true" ]]; then | |
| GRADLE_PROPS="$OUTPUT_DIR/gradle.properties" | |
| echo "" >> "$GRADLE_PROPS" | |
| echo "# Enable Metro's Circuit code generation" >> "$GRADLE_PROPS" | |
| echo "metro.enableCircuitCodegen=true" >> "$GRADLE_PROPS" | |
| fi | |
| # Optionally init fresh git repo | |
| if [[ "$INIT_GIT" == "true" ]]; then | |
| (cd "$OUTPUT_DIR" && git init -q && git add -A && git commit -q -m "Initial commit from KMP Wizard ($BRANCH)") | |
| fi | |
| echo | |
| echo "Done! Project created at: $OUTPUT_DIR" | |
| echo | |
| echo " cd $OUTPUT_DIR" | |
| echo " ./gradlew build" | |
| echo |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment