Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
aaf87a2
fix: auto-generate JetBrains CLI resources for runIde
kirillk Apr 10, 2026
4b41cbf
feat(jetbrains): add KiloAppService as single app-level orchestrator
kirillk Apr 12, 2026
49367eb
refactor(jetbrains): rename backend classes to KiloBackend* prefix an…
kirillk Apr 12, 2026
4aa3ad7
refactor(jetbrains): reorganize frontend and RPC packages
kirillk Apr 12, 2026
6a0ba29
refactor(jetbrains): rename KiloProjectRpcApi to KiloAppRpcApi, remov…
kirillk Apr 12, 2026
dafe3b5
refactor(jetbrains): make frontend KiloAppService app-level
kirillk Apr 12, 2026
a729eab
fix(jetbrains): centralize lifecycle mutex and fix reconnection issues
kirillk Apr 12, 2026
600f4df
feat(jetbrains): add KiloAppState with loading phase, retry, and erro…
kirillk Apr 12, 2026
8a617ee
fix(jetbrains): resolve bun path for Gradle daemon, fix Config.model …
kirillk Apr 12, 2026
c2d6a0a
fix(jetbrains): move findBun into PrepareLocalCliTask to avoid non-st…
kirillk Apr 12, 2026
0a4aa19
feat(jetbrains): switch generated HTTP client from Moshi to kotlinx.s…
kirillk Apr 12, 2026
f7f6a27
refactor(jetbrains): move buildSrc to build-tasks composite build
kirillk Apr 12, 2026
6496c74
refactor(jetbrains): clean up section divider comments and simplify b…
kirillk Apr 12, 2026
ac87871
feat(jetbrains): add IntelliJ-independent backend test suite
kirillk Apr 13, 2026
7530e33
refactor(jetbrains): strip KiloBackendProjectService to project-only …
kirillk Apr 13, 2026
c807564
Merge branch 'main' into lime-dormouse
kirillk Apr 13, 2026
8b880ed
fix(jetbrains): forward health flag from server, restrict profile fal…
kirillk Apr 13, 2026
23745d2
chore(jetbrains): bump kotlinx-serialization to 1.8.1 (latest for Kot…
kirillk Apr 13, 2026
7115a4b
moving things
kirillk Apr 13, 2026
486ba67
Merge branch 'main' into lime-dormouse
kirillk Apr 13, 2026
fa767a8
Merge branch 'main' into lime-dormouse
kirillk Apr 13, 2026
bc5be5b
Merge branch 'main' into lime-dormouse
kirillk Apr 14, 2026
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
8 changes: 5 additions & 3 deletions packages/kilo-jetbrains/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,17 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.zip

## Run the plugin

After building, use the `runIde` Gradle task (available in the Gradle tool window or via the "Run JetBrains Plugin" run configuration) to launch a sandboxed IntelliJ instance with the plugin installed.
Use the `runIde` Gradle task (available in the Gradle tool window or via the "Run JetBrains Plugin" run configuration) to launch a sandboxed IntelliJ instance with the plugin installed.

Note: `runIde` does not build CLI binaries -- run `bun run build` at least once before using it. Subsequent Kotlin/Gradle changes can be iterated with `runIde` directly.
On a fresh worktree, `runIde` now checks `backend/build/generated/cli/cli/` first. If the local-platform CLI binary is missing, it runs the standard single-binary generation flow and copies the result into the backend resources automatically.

That bootstrap is local-development only. Production packaging still requires running `bun run build:production` so all platform binaries are present.

---

## Run Gradle directly

You can run `./gradlew buildPlugin` directly if CLI binaries are already in `backend/build/generated/cli/`. Gradle will fail with a clear error if they are missing.
You can run `./gradlew buildPlugin` directly for local development. Gradle will auto-generate the current-platform CLI binary if `backend/build/generated/cli/` is missing.

For production verification:

Expand Down
120 changes: 44 additions & 76 deletions packages/kilo-jetbrains/backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.openapi.generator)
id("build-tasks")
}

kotlin {
Expand All @@ -27,23 +28,22 @@ openApiGenerate {
apiPackage.set("ai.kilocode.jetbrains.api.client")
modelPackage.set("ai.kilocode.jetbrains.api.model")
configOptions.set(mapOf(
"serializationLibrary" to "moshi",
"serializationLibrary" to "kotlinx_serialization",
"omitGradleWrapper" to "true",
"omitGradlePluginVersions" to "true",
"useCoroutines" to "false",
"sourceFolder" to "src/main/kotlin",
"enumPropertyNaming" to "UPPERCASE",
))
// Remap schema "File" so the generated class is not named java.io.File
modelNameMappings.set(mapOf(
"File" to "DiffFileInfo",
))
// Map empty anyOf references to kotlin.Any
typeMappings.set(mapOf(
"AnyOfLessThanGreaterThan" to "kotlin.Any",
"anyOf<>" to "kotlin.Any",
"number" to "kotlin.Double",
"decimal" to "kotlin.Double",
))
// Normalise OpenAPI 3.1 → 3.0-compatible patterns
openapiNormalizer.set(mapOf(
"SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING" to "true",
"SIMPLIFY_ONEOF_ANYOF" to "true",
Expand All @@ -54,51 +54,9 @@ openApiGenerate {
generateModelDocumentation.set(false)
}

// Fix openapi-generator 3.1.1 codegen bugs in generated Kotlin sources.
//
// The OpenAPI spec uses `const: true` on boolean fields (e.g. `healthy`).
// openapi-generator turns these into single-value enum classes:
//
// val healthy: GlobalHealth200Response.Healthy
// enum class Healthy(val value: kotlin.Boolean) { @Json(name = "true") TRUE("true") }
//
// Moshi's EnumJsonAdapter calls nextString() for the value, but the server sends
// a JSON boolean `true`, not a JSON string `"true"`, causing:
// JsonDataException: Expected a string but was BOOLEAN at path $.healthy
//
// Fix: replace the enum field type with kotlin.Boolean, remove the enum class.
val fixGeneratedApi by tasks.registering {
val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
dependsOn("openApiGenerate")
val dir = generatedApi
doLast {
// Regex to find boolean const enum declarations inside data classes.
// Captures the enum name so we can find and fix the corresponding field.
val enumDecl = Regex(
"""enum class (\w+)\(val value: kotlin\.Boolean\)"""
)
dir.get().asFile.walkTopDown().filter { it.extension == "kt" }.forEach { file ->
var text = file.readText()
val names = enumDecl.findAll(text).map { it.groupValues[1] }.toList()
if (names.isEmpty()) return@forEach

for (name in names) {
// Replace field type: `val foo: EnclosingClass.EnumName` → `val foo: kotlin.Boolean`
text = text.replace(Regex("""(val \w+:\s*)\w+\.$name""")) { m ->
"${m.groupValues[1]}kotlin.Boolean"
}
// Remove the @JsonClass annotation + enum class block
text = text.replace(Regex(
"""\n\s*@JsonClass\(generateAdapter = false\)\s*\n\s*enum class $name\(val value: kotlin\.Boolean\)\s*\{[^}]*\}"""
), "")
// Remove the orphaned KDoc block that preceded the enum (lines of ` *` ending with `*/`)
// These look like: \n /**\n * \n *\n * Values: TRUE\n */
text = text.replace(Regex(
"""\n\s*/\*\*\s*\n(\s*\*[^\n]*\n)*\s*\*/\s*(?=\n\s*\n)"""
), "")
}
file.writeText(text)
}
}
generated.set(generatedApi)
}

tasks.named("compileKotlin") {
Expand All @@ -117,33 +75,36 @@ val requiredPlatforms = listOf(
"windows-arm64",
)

val checkCli by tasks.registering {
val localCli by tasks.registering(PrepareLocalCliTask::class) {
description = "Prepare local CLI binary for JetBrains dev"
val os = providers.systemProperty("os.name").map {
val name = it.lowercase()
if (name.contains("mac")) return@map "darwin"
if (name.contains("win")) return@map "windows"
if (name.contains("linux")) return@map "linux"
throw GradleException("Unsupported host OS: $it")
}
val arch = providers.systemProperty("os.arch").map {
val name = it.lowercase()
if (name == "aarch64" || name == "arm64") return@map "arm64"
if (name == "x86_64" || name == "amd64") return@map "x64"
throw GradleException("Unsupported host arch: $it")
}
script.set(rootProject.layout.projectDirectory.file("script/build.ts"))
root.set(rootProject.layout.projectDirectory)
out.set(cliDir)
platform.set(os.zip(arch) { a, b -> "$a-$b" })
exe.set(platform.map { if (it.startsWith("windows")) "kilo.exe" else "kilo" })
}

val prod = production
val checkCli by tasks.registering(CheckCliTask::class) {
description = "Verify CLI binaries exist before building"
val dir = cliDir.map { it.asFile }
val prod = production.get()
val platforms = requiredPlatforms.toList()
doLast {
val resolved = dir.get()
if (!resolved.exists() || resolved.listFiles()?.isEmpty() != false) {
throw GradleException(
"CLI binaries not found at ${resolved.absolutePath}.\n" +
"Run 'bun run build' from packages/kilo-jetbrains/ to build CLI and plugin together."
)
}
if (prod) {
val missing = platforms.filter { platform ->
val dir = File(resolved, platform)
val exe = if (platform.startsWith("windows")) "kilo.exe" else "kilo"
!File(dir, exe).exists()
}
if (missing.isNotEmpty()) {
throw GradleException(
"Production build requires all platform CLI binaries.\n" +
"Missing: ${missing.joinToString(", ")}\n" +
"Run 'bun run build:production' to build all platforms."
)
}
}
dir.set(cliDir)
this.production.set(prod)
platforms.set(requiredPlatforms)
if (!prod.get()) {
dependsOn(localCli)
}
}

Expand All @@ -162,6 +123,13 @@ dependencies {
implementation(project(":shared"))
implementation(libs.okhttp)
implementation(libs.okhttp.sse)
implementation(libs.moshi)
implementation(libs.moshi.kotlin)
implementation(libs.kotlinx.serialization.json)

testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(kotlin("test"))
}

tasks.test {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package ai.kilocode.backend

/**
* Abstraction over the CLI process lifecycle.
*
* Production: [KiloBackendCliManager]. Tests: fake returning mock server port.
*/
interface CliServer {
sealed class State {
data class Ready(val port: Int, val password: String) : State()
data class Error(val message: String, val details: String? = null) : State()
}

var forceExtract: Boolean
fun process(): Process?
suspend fun init(): State
fun exited(proc: Process)
fun stop()
fun dispose()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package ai.kilocode.backend

import ai.kilocode.jetbrains.api.model.Config
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloProfile200Response

/**
* Full application lifecycle state, combining CLI transport connection
* status with data-loading progress.
*
* [ConnectionState] stays internal to [KiloConnectionService] for the
* transport layer. This sealed class is what the frontend observes.
*/
sealed class KiloAppState {
data object Disconnected : KiloAppState()
data object Connecting : KiloAppState()
data class Loading(val progress: LoadProgress) : KiloAppState()
data class Ready(val data: AppData) : KiloAppState()
data class Error(val message: String, val errors: List<LoadError> = emptyList()) : KiloAppState()
}

/**
* Tracks which global data fetches have completed during the [KiloAppState.Loading] phase.
*/
data class LoadProgress(
val config: Boolean = false,
val notifications: Boolean = false,
val profile: ProfileResult = ProfileResult.PENDING,
)

/** Outcome of the profile fetch. */
enum class ProfileResult { PENDING, LOADED, NOT_LOGGED_IN }

/**
* Error detail for a single resource that failed to load.
*/
data class LoadError(
val resource: String,
val status: Int? = null,
val detail: String? = null,
)

/**
* All global data that has been successfully loaded.
* Present only in [KiloAppState.Ready].
*/
data class AppData(
val profile: KiloProfile200Response?,
val config: Config,
val notifications: List<KiloNotifications200ResponseInner>,
)
Loading
Loading