Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions src/frameworks/kmp/kmp-wizard-agent.ts
Original file line number Diff line number Diff line change
@@ -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:<VERSION>") 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 = "<POSTHOG_PROJECT_TOKEN>", host = "<POSTHOG_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',
],
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export enum Integration {
fastapi = 'fastapi',
laravel = 'laravel',
sveltekit = 'sveltekit',
kmp = 'kmp',
swift = 'swift',
android = 'android',
rails = 'rails',
Expand Down
69 changes: 69 additions & 0 deletions src/lib/detection/__tests__/framework.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>): string {
Expand Down Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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': '<manifest/>',
'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',
Expand Down Expand Up @@ -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);
});
});
2 changes: 2 additions & 0 deletions src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,6 +38,7 @@ export const FRAMEWORK_REGISTRY: Record<Integration, FrameworkConfig> = {
[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,
Expand Down
Loading