diff --git a/src/frameworks/kmp/kmp-wizard-agent.ts b/src/frameworks/kmp/kmp-wizard-agent.ts new file mode 100644 index 00000000..0b798f9e --- /dev/null +++ b/src/frameworks/kmp/kmp-wizard-agent.ts @@ -0,0 +1,102 @@ +/* Kotlin Multiplatform (KMP) wizard using posthog-agent with PostHog MCP */ +import type { FrameworkConfig } from '../../lib/framework-config'; +import { Integration } from '../../lib/constants'; +import { gradlePackageManager } from '../../lib/detection/package-manager'; +import fg from 'fast-glob'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export const KMP_AGENT_CONFIG: FrameworkConfig = { + metadata: { + name: 'Kotlin Multiplatform', + integration: Integration.kmp, + beta: true, + docsUrl: 'https://posthog.com/docs/libraries/kmp', + preRunNotice: + 'The PostHog Kotlin Multiplatform SDK is in early access (0.x pre-release). The API may change between minor versions.', + }, + + detection: { + packageName: 'posthog-kmp', + packageDisplayName: 'Kotlin Multiplatform', + usesPackageJson: false, + getVersion: () => undefined, + detectPackageManager: gradlePackageManager, + // KMP is detected before Android/Swift because a KMP project also looks like + // an Android and/or Swift project. Detection therefore requires the Kotlin + // Multiplatform Gradle plugin or a `commonMain` source set — signals that a + // plain Android or Swift project does not have. + detect: async (options) => { + const { installDir } = options; + + // Strategy 1: a Gradle build file that applies the Kotlin Multiplatform plugin. + const buildFiles = await fg(['**/build.gradle', '**/build.gradle.kts'], { + cwd: installDir, + ignore: ['**/build/**', '**/node_modules/**', '**/.gradle/**'], + }); + + for (const file of buildFiles) { + const content = fs.readFileSync(path.join(installDir, file), 'utf-8'); + if ( + content.includes('kotlin("multiplatform")') || + content.includes('org.jetbrains.kotlin.multiplatform') || + content.includes('kotlin-multiplatform') + ) { + return true; + } + } + + // Strategy 2: a KMP `commonMain` source set exists. + const commonMain = await fg('**/src/commonMain', { + cwd: installDir, + onlyDirectories: true, + ignore: ['**/build/**', '**/node_modules/**', '**/.gradle/**'], + }); + + return commonMain.length > 0; + }, + }, + + environment: { + uploadToHosting: false, + getEnvVars: (apiKey: string, host: string) => ({ + POSTHOG_PROJECT_TOKEN: apiKey, + POSTHOG_HOST: host, + }), + }, + + analytics: { + getTags: () => ({}), + }, + + prompts: { + projectTypeDetection: + 'This is a Kotlin Multiplatform (KMP) project. Look for a build.gradle.kts (or build.gradle) that applies the Kotlin Multiplatform plugin (kotlin("multiplatform") or org.jetbrains.kotlin.multiplatform) and a shared module with a src/commonMain source set.', + packageInstallation: + 'Add the PostHog KMP SDK to the shared module\'s commonMain dependencies in build.gradle.kts: within the kotlin { sourceSets { ... } } block, add implementation("com.posthog:posthog-kmp:") to commonMain.dependencies. Match the existing dependency format (Groovy vs Kotlin DSL).', + getAdditionalContextLines: () => [ + 'Framework docs ID: kmp (use posthog://docs/frameworks/kmp for documentation)', + 'Initialize once, early in the app lifecycle, from shared code. All PostHog APIs live in the com.posthog.kmp package.', + 'Setup call: PostHog.setup(config = PostHogConfig(apiKey = "", host = ""), context = PostHogContext())', + 'PostHogContext is platform-specific: on Android pass the Application via PostHogContext(application); on iOS and Web use the no-argument PostHogContext().', + 'Imports: com.posthog.kmp.PostHog, com.posthog.kmp.PostHogConfig, com.posthog.kmp.PostHogContext.', + ], + }, + + ui: { + successMessage: 'PostHog integration complete', + estimatedDurationMinutes: 5, + getOutroChanges: () => [ + 'Analyzed your Kotlin Multiplatform project structure', + 'Added the PostHog KMP SDK to your shared module', + 'Configured PostHog initialization in your shared code', + 'Added event capture and user identification', + ], + getOutroNextSteps: () => [ + 'Wire up the platform-specific PostHogContext for each target (Android/iOS/Web)', + 'Build and run your app to see PostHog in action', + 'Visit your PostHog dashboard to see incoming events', + 'Check out the PostHog KMP docs for feature flags, session replay, and more', + ], + }, +}; diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts index c109c31d..929838af 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts @@ -50,6 +50,7 @@ const INTEGRATION_ENTRIES = [ { id: 'integration-elixir' }, { id: 'integration-go' }, { id: 'integration-swift', framework: 'swift' }, + { id: 'integration-kmp', framework: 'kmp' }, { id: 'integration-flutter' }, { id: 'integration-react-native', framework: 'react-native', default: true }, { id: 'integration-expo', framework: 'react-native' }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e182e6d2..c71acfa1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -96,6 +96,7 @@ export enum Integration { fastapi = 'fastapi', laravel = 'laravel', sveltekit = 'sveltekit', + kmp = 'kmp', swift = 'swift', android = 'android', rails = 'rails', diff --git a/src/lib/detection/__tests__/framework.test.ts b/src/lib/detection/__tests__/framework.test.ts index 154613a3..075c46a2 100644 --- a/src/lib/detection/__tests__/framework.test.ts +++ b/src/lib/detection/__tests__/framework.test.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import { detectFramework } from '@lib/detection/framework'; import { Integration } from '@lib/constants'; import { ANDROID_AGENT_CONFIG } from '../../../frameworks/android/android-wizard-agent'; +import { KMP_AGENT_CONFIG } from '../../../frameworks/kmp/kmp-wizard-agent'; /** A throwaway project dir seeded with the given files. */ function makeProject(files: Record): string { @@ -52,6 +53,17 @@ describe('Integration enum order (drives first-match detection)', () => { // javascriptNode matches any package.json; anything after it is unreachable. expect(order[order.length - 1]).toBe(Integration.javascriptNode); }); + + test('kmp is ordered before android and swift (more specific detection wins)', () => { + // A KMP project also looks like an Android/Swift project, so KMP must be + // checked first for first-match detection to resolve it correctly. + expect(order.indexOf(Integration.kmp)).toBeLessThan( + order.indexOf(Integration.android), + ); + expect(order.indexOf(Integration.kmp)).toBeLessThan( + order.indexOf(Integration.swift), + ); + }); }); describe('detectFramework (end-to-end over real project dirs)', () => { @@ -103,6 +115,28 @@ describe('detectFramework (end-to-end over real project dirs)', () => { ); }); + test('a Kotlin Multiplatform project resolves to kmp', async () => { + const opts = project({ + 'settings.gradle.kts': 'include(":shared")', + 'shared/build.gradle.kts': `plugins { kotlin("multiplatform") }\nkotlin {\n sourceSets {\n commonMain.dependencies {}\n }\n}\n`, + 'shared/src/commonMain/kotlin/App.kt': 'class App', + }); + await expect(detectFramework(opts.installDir)).resolves.toBe( + Integration.kmp, + ); + }); + + test('a plain Android project still resolves to android (kmp ordered ahead does not hijack it)', async () => { + const opts = project({ + 'build.gradle': `plugins { id 'com.android.application' }`, + 'app/src/main/AndroidManifest.xml': '', + 'app/src/main/kotlin/MainActivity.kt': 'class MainActivity', + }); + await expect(detectFramework(opts.installDir)).resolves.toBe( + Integration.android, + ); + }); + test('a Flutter project is not claimed by anything', async () => { const opts = project({ 'pubspec.yaml': 'name: my_flutter_app\nenvironment:\n sdk: ^3.0.0\n', @@ -134,3 +168,38 @@ describe('android detect', () => { await expect(detect(opts)).resolves.toBe(false); }); }); + +describe('kmp detect', () => { + const detect = KMP_AGENT_CONFIG.detection.detect; + + test('claims a project applying the Kotlin Multiplatform plugin', async () => { + const opts = project({ + 'shared/build.gradle.kts': `plugins { kotlin("multiplatform") }`, + }); + await expect(detect(opts)).resolves.toBe(true); + }); + + test('claims a project with a commonMain source set', async () => { + const opts = project({ + 'shared/src/commonMain/kotlin/App.kt': 'class App', + }); + await expect(detect(opts)).resolves.toBe(true); + }); + + test('does not claim a plain Android project', async () => { + const opts = project({ + 'build.gradle': `plugins { id 'com.android.application' }`, + 'app/src/main/kotlin/MainActivity.kt': 'class MainActivity', + }); + await expect(detect(opts)).resolves.toBe(false); + }); + + test('does not claim a Flutter project', async () => { + const opts = project({ + 'pubspec.yaml': 'name: my_flutter_app\nenvironment:\n sdk: ^3.0.0\n', + 'android/build.gradle': `plugins { id 'com.android.application' }`, + 'android/app/src/main/kotlin/MainActivity.kt': 'class MainActivity', + }); + await expect(detect(opts)).resolves.toBe(false); + }); +}); diff --git a/src/lib/registry.ts b/src/lib/registry.ts index bf9dcae6..2dfba061 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -15,6 +15,7 @@ import { FASTAPI_AGENT_CONFIG } from '@frameworks/fastapi/fastapi-wizard-agent'; import { LARAVEL_AGENT_CONFIG } from '@frameworks/laravel/laravel-wizard-agent'; import { SVELTEKIT_AGENT_CONFIG } from '@frameworks/svelte/svelte-wizard-agent'; import { SWIFT_AGENT_CONFIG } from '@frameworks/swift/swift-wizard-agent'; +import { KMP_AGENT_CONFIG } from '@frameworks/kmp/kmp-wizard-agent'; import { ANDROID_AGENT_CONFIG } from '@frameworks/android/android-wizard-agent'; import { RAILS_AGENT_CONFIG } from '@frameworks/rails/rails-wizard-agent'; import { PYTHON_AGENT_CONFIG } from '@frameworks/python/python-wizard-agent'; @@ -37,6 +38,7 @@ export const FRAMEWORK_REGISTRY: Record = { [Integration.fastapi]: FASTAPI_AGENT_CONFIG, [Integration.laravel]: LARAVEL_AGENT_CONFIG, [Integration.sveltekit]: SVELTEKIT_AGENT_CONFIG, + [Integration.kmp]: KMP_AGENT_CONFIG, [Integration.swift]: SWIFT_AGENT_CONFIG, [Integration.android]: ANDROID_AGENT_CONFIG, [Integration.rails]: RAILS_AGENT_CONFIG,