Skip to content

Instantly share code, notes, and snippets.

@ZacSweers
Last active April 7, 2026 09:26
Show Gist options
  • Select an option

  • Save ZacSweers/5106c32076db37d5e967a375dcf2c63f to your computer and use it in GitHub Desktop.

Select an option

Save ZacSweers/5106c32076db37d5e967a375dcf2c63f to your computer and use it in GitHub Desktop.
kmp-new.sh
#!/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