From 2746e69a138189ba7d6aba1f8e78c619cb60794b Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 6 Jul 2026 15:06:23 -0400 Subject: [PATCH 1/7] feat(jetbrains): download CLI releases at runtime --- .changeset/jetbrains-download-cli.md | 5 + packages/kilo-jetbrains/.gitignore | 5 + packages/kilo-jetbrains/AGENTS.md | 21 +-- packages/kilo-jetbrains/README.md | 21 +-- .../kilo-jetbrains/backend/build.gradle.kts | 62 +++---- .../ai/kilocode/backend/app/KiloAppState.kt | 1 + .../backend/app/KiloBackendAppService.kt | 9 +- .../app/KiloBackendConnectionService.kt | 13 +- .../backend/app/KiloBackendSessionManager.kt | 10 +- .../ai/kilocode/backend/cli/CliServer.kt | 8 +- .../backend/cli/KiloBackendCliManager.kt | 74 +------- .../kilocode/backend/cli/KiloCliDownloader.kt | 129 ++++++++++++++ .../kilocode/backend/cli/KiloCliPlatform.kt | 26 +++ .../ai/kilocode/backend/cli/KiloProps.kt | 16 ++ .../kilocode/backend/rpc/KiloAppRpcApiImpl.kt | 17 +- .../kilocode/backend/app/KiloAppStateTest.kt | 1 + .../backend/app/KiloBackendAppServiceTest.kt | 48 ++++- .../backend/app/KiloConnectionServiceTest.kt | 43 ++++- .../backend/cli/KiloCliDownloaderTest.kt | 104 +++++++++++ .../kilocode/backend/testing/FakeCliServer.kt | 7 +- .../build-tasks/build.gradle.kts | 1 + .../src/main/kotlin/BuildTasksPlugin.kt | 4 +- .../src/main/kotlin/CheckCliTask.kt | 59 ------- .../main/kotlin/GenerateOpenApiSpecTask.kt | 166 ++++++++++++------ .../src/main/kotlin/PrepareLocalCliTask.kt | 139 --------------- packages/kilo-jetbrains/build.gradle.kts | 14 +- .../ai/kilocode/client/app/KiloAppService.kt | 28 ++- .../ai/kilocode/client/session/SessionUi.kt | 7 + .../session/controller/SessionController.kt | 8 + .../controller/SessionControllerEvent.kt | 4 + .../client/session/ui/ConnectionPanel.kt | 18 ++ .../session/ui/empty/EmptySessionPanel.kt | 23 ++- .../settings/profile/LoggedOutProfileUi.kt | 2 +- .../client/settings/profile/ProfileUi.kt | 2 +- .../resources/messages/KiloBundle.properties | 3 + .../client/session/ui/ConnectionPanelTest.kt | 14 ++ .../session/ui/EmptySessionPanelTest.kt | 17 ++ .../kilocode/client/testing/FakeAppRpcApi.kt | 6 + .../kilo-jetbrains/gradle/libs.versions.toml | 2 + .../kilo-jetbrains/script/build-version.sh | 10 +- packages/kilo-jetbrains/script/build.ts | 117 +----------- .../kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt | 3 + .../ai/kilocode/rpc/dto/KiloAppStateDto.kt | 4 + 43 files changed, 724 insertions(+), 547 deletions(-) create mode 100644 .changeset/jetbrains-download-cli.md create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloProps.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt delete mode 100644 packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt delete mode 100644 packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt diff --git a/.changeset/jetbrains-download-cli.md b/.changeset/jetbrains-download-cli.md new file mode 100644 index 00000000000..013e6299358 --- /dev/null +++ b/.changeset/jetbrains-download-cli.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Download the pinned Kilo CLI release at connect time instead of bundling CLI binaries in the plugin. diff --git a/packages/kilo-jetbrains/.gitignore b/packages/kilo-jetbrains/.gitignore index e44cae7a20a..27ac8545f72 100644 --- a/packages/kilo-jetbrains/.gitignore +++ b/packages/kilo-jetbrains/.gitignore @@ -16,3 +16,8 @@ bin/ **/.kotlin/ **/bin/ !gradle/wrapper/gradle-wrapper.jar + +# Downloaded CLI artifacts +**/cli-cache/ +kilo-*.zip +kilo-*.tar.gz diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 5374daaa28b..05dbd6c0d10 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -14,7 +14,7 @@ - `plugin.xml` `` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`) - Service classes ↔ ``/`` entries in the corresponding module XML -- `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list +- `packages/kilo-jetbrains/package.json` version ↔ GitHub CLI release tag consumed by the backend downloader ## IntelliJ Platform Source Lookup @@ -153,7 +153,9 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi ## CLI Integration -- CLI process spawning, extraction, and lifecycle belong in `backend`. +- CLI process spawning, download, extraction, and lifecycle belong in `backend`. +- The plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` for split-mode RPC and runtime use. +- The generated API client is produced from the pinned release binary by running `kilo generate` during the Gradle OpenAPI generation task. - For OS and environment checks, prefer IntelliJ Platform classes over raw JVM APIs such as `System.getProperty(...)` or `System.getenv(...)`. - Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`. - Detect OS with `com.intellij.openapi.util.SystemInfo.isMac` / `isLinux` / `isWindows`. @@ -187,22 +189,21 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi ## Build and Verification -- **Marketplace version build**: Use `script/build-version.sh ` from `packages/kilo-jetbrains/` to clean, prepare production CLI binaries, build, sign, and verify the JetBrains Marketplace plugin ZIP. Pass `--skip-verification` only when explicitly needed. +- **Marketplace version build**: Use `script/build-version.sh ` from `packages/kilo-jetbrains/` to clean, build, sign, and verify the JetBrains Marketplace plugin ZIP. Pass `--skip-verification` only when explicitly needed. - **Test version build**: If the user asks for a JetBrains test build, still require a version and use `script/build-version.sh --skip-signing --skip-verification` from `packages/kilo-jetbrains/` so no signing secrets are needed. Add `--skip-clean` only when the user wants a faster incremental test build. - **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. Does NOT require CLI binaries. -- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (prepares CLI binaries + runs Gradle `buildPlugin`). -- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present in `backend/build/generated/cli/`; run `bun run build --prepare-cli` first). +- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (runs Gradle `buildPlugin`). +- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/`. - **Java checks**: Do not run `java -version` as a routine preflight. Gradle commands already fail clearly when Java is missing or incompatible; check Java only when diagnosing that failure mode. - **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root. -- **Run split mode**: `./gradlew --no-configuration-cache runIdeSplitMode` or the checked-in `Run IDE (Split Mode)` configuration — launches backend and frontend locally and prepares the local-platform CLI binary automatically. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`). -- **Run split backend**: `./gradlew --no-configuration-cache runIdeBackend` — prepares the local-platform CLI binary automatically; if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting. +- **Run split mode**: `./gradlew --no-configuration-cache runIdeSplitMode` or the checked-in `Run IDE (Split Mode)` configuration — launches backend and frontend locally. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`). +- **Run split backend**: `./gradlew --no-configuration-cache runIdeBackend` — if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting. - **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries. ### CLI/SDK Change Awareness -- JetBrains runtime behavior depends on the bundled CLI artifact under `backend/build/generated/cli/`; Gradle-only tasks and sandbox runs do not rebuild it. -- If there are relevant changes outside `packages/kilo-jetbrains/` in the CLI (`packages/opencode/`) or SDK/API generation paths (`packages/sdk/js/`, server endpoints, OpenAPI outputs), warn the user that the JetBrains run may be using stale generated CLI or SDK artifacts. -- Do not regenerate or rebuild those artifacts automatically just because such changes exist. Ask the user whether to refresh them first, typically with `bun run build --prepare-cli` from `packages/kilo-jetbrains/` for CLI artifacts and `./script/generate.ts` from the repo root for server/API SDK changes. +- JetBrains runtime behavior depends on the downloaded CLI release pinned by `packages/kilo-jetbrains/package.json`; local `packages/opencode/` changes are not used unless published and pinned. +- If there are relevant server/API changes outside `packages/kilo-jetbrains/`, warn the user that JetBrains may need a newly published/pinned CLI release and regenerated SDK artifacts. ## UI Guidelines diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index 78978d66337..d35a08a8065 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -10,7 +10,7 @@ To try the v7 Early Access Program plugin, follow the [JetBrains EAP installatio ### Prerequisites -- **Bun** -- used to build CLI binaries and run build scripts +- **Bun** -- used to run package build scripts - **JDK 21+** -- required by Gradle and the IntelliJ Platform SDK. Check with `java -version`. The preferred way to install is via [SDKMAN](https://sdkman.io/install): ```bash @@ -34,7 +34,7 @@ When working in a git worktree (e.g. via the Agent Manager), run `bun install` f bun install ``` -This installs Node dependencies required by the build scripts, including `script/build.ts` which prepares CLI binaries. +This installs Node dependencies required by the build scripts. --- @@ -52,7 +52,7 @@ From `packages/kilo-jetbrains/`: bun run build ``` -This builds the CLI binary for your current OS/arch only, copies it into the backend module resources, and runs `./gradlew buildPlugin`. The plugin archive is output to `build/distributions/`. +This builds the plugin without bundling CLI binaries. The backend downloads the pinned Kilo CLI release for the host platform at connect time. The plugin archive is output to `build/distributions/`. Or via Turbo from the repo root: @@ -70,7 +70,7 @@ From `packages/kilo-jetbrains/`: bun run build:production ``` -This builds CLI binaries for all 6 desktop platforms (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64, windows-arm64), copies them all into the backend jar, and fails if any are missing. Gradle also validates all platforms are present via `-Pproduction=true`. +This builds the plugin without bundling CLI binaries. The backend downloads the pinned Kilo CLI release for the host platform at connect time. The built plugin archive is at `build/distributions/kilo.jetbrains-.zip`. This zip can be installed in any JetBrains IDE via **Settings > Plugins > Install Plugin from Disk**. @@ -84,15 +84,15 @@ See [RELEASING.md](RELEASING.md) for the full release process, including how to ## Run the plugin -Use the checked-in `Run IDE (Split Mode)` run configuration (or `./gradlew --no-configuration-cache runIdeSplitMode` from `packages/kilo-jetbrains/`) to launch the backend and frontend halves of a local split-mode sandbox. The task prepares the local-platform CLI binary automatically when `backend/build/generated/cli/cli/` does not contain the expected binary. +Use the checked-in `Run IDE (Split Mode)` run configuration (or `./gradlew --no-configuration-cache runIdeSplitMode` from `packages/kilo-jetbrains/`) to launch the backend and frontend halves of a local split-mode sandbox. The backend downloads the pinned CLI release on first connect. -Use `runIde` only when you need a monolithic sandboxed IntelliJ instance. `runIde` does not prepare the CLI binary automatically, so run `bun run build --prepare-cli` from `packages/kilo-jetbrains/` first to copy the local-platform binary into `backend/build/generated/cli/cli/`. +Use `runIde` only when you need a monolithic sandboxed IntelliJ instance. -Production packaging still requires running `bun run build:production` so all platform binaries are present. +Production packaging uses `bun run build:production` and still downloads the host CLI at runtime. ### Run the split backend -Use the checked-in `Run IDE (Backend)` run configuration (or `./gradlew --no-configuration-cache runIdeBackend`) to launch just the backend half of a split-mode session. It prepares the local-platform CLI binary automatically when `backend/build/generated/cli/cli/` does not contain the expected binary. +Use the checked-in `Run IDE (Backend)` run configuration (or `./gradlew --no-configuration-cache runIdeBackend`) to launch just the backend half of a split-mode session. If `Run IDE (Backend)` exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting the backend. @@ -107,7 +107,6 @@ All properties below are passed with `-P` on the Gradle command line or in the r | `kilo.splitModeServerPort` | `0` | Backend split-mode server port. `0` or omitted lets the IntelliJ Platform Gradle Plugin pick a free port when the task runs. | | `kilo.dev.storage.isolated` | `false` | When `true`, CLI runs with `XDG_*_HOME` pointing to `.kilo-dev/` in the worktree root, fully isolating dev storage from your real Kilo installation. Enabled by default in the checked-in split-mode run configurations. | | `kilo.dev.worktree.root` | monorepo root | Worktree root used to resolve `.kilo-dev/`. Auto-detected from the Gradle project directory; override only when the auto-detection is wrong. | -| `kilo.bun.path` | `bun` on `$PATH` | Absolute path to Bun. Set this when IntelliJ-launched Gradle cannot find Bun automatically. | The checked-in IDE run configurations pass `--no-configuration-cache` because the IntelliJ Platform Gradle Plugin run-IDE tasks are not configuration-cache compatible in this setup. They also pass `--purge-old-log-directories` so stale sandbox logs do not hide the current backend and frontend `kilo-dev.log` files. @@ -195,9 +194,7 @@ For direct local packaging, run: bun run build ``` -This prepares the local CLI binary and then runs `./gradlew buildPlugin`. - -If you run `./gradlew buildPlugin` directly, Gradle verifies CLI binaries are present but does not build them first. Run `bun run build --prepare-cli` beforehand if the binaries are missing. +This runs `./gradlew buildPlugin`. For production verification: diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 8139adcb753..65da6183abe 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -1,4 +1,5 @@ import normalization.NormalizeOpenApiSpecTask +import org.gradle.api.tasks.WriteProperties plugins { alias(libs.plugins.rpc) @@ -15,18 +16,32 @@ kotlin { val generatedApi = layout.buildDirectory.dir("generated/openapi/src/main/kotlin") val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.json") val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json") +val generatedProps = layout.buildDirectory.dir("generated/kilo-props") + +val pinnedCliVersion = providers.provider { + val text = rootProject.layout.projectDirectory.file("package.json").asFile.readText() + Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1) + ?: error("Could not read version from package.json") +} sourceSets { main { - resources.srcDir(layout.buildDirectory.dir("generated/cli")) + resources.srcDir(generatedProps) kotlin.srcDir(generatedApi) } } +val writeKiloProperties by tasks.registering(WriteProperties::class) { + description = "Write pinned Kilo CLI properties" + val out = generatedProps.map { it.file("kilo.properties") } + destinationFile.set(out) + property("cli.version", pinnedCliVersion) +} + val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) { description = "Generate CLI OpenAPI spec into the build directory" - opencodeDir.set(rootProject.layout.projectDirectory.dir("../opencode")) - serverSrcDir.set(rootProject.layout.projectDirectory.dir("../opencode/src/server")) + cliVersion.set(pinnedCliVersion) + cacheDir.set(layout.buildDirectory.dir("cli-cache")) spec.set(rawSpec) } @@ -83,49 +98,19 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) { } tasks.named("compileKotlin") { - dependsOn(fixGeneratedApi) + dependsOn(fixGeneratedApi, writeKiloProperties) inputs.dir(generatedApi) } +tasks.named("processResources") { + dependsOn(writeKiloProperties) +} + tasks.named("compileTestKotlin") { dependsOn(fixGeneratedApi) inputs.dir(generatedApi) } -val cliDir = layout.buildDirectory.dir("generated/cli/cli") -val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false) - -val prepareLocalCli by tasks.registering(PrepareLocalCliTask::class) { - description = "Prepare the local-platform CLI binary for JetBrains backend runs" - root.set(rootProject.layout.projectDirectory) - dir.set(cliDir) - bunPath.convention( - providers.gradleProperty("kilo.bun.path") - .orElse(providers.environmentVariable("BUN_EXE")) - ) -} - -val requiredPlatforms = listOf( - "darwin-arm64", - "darwin-x64", - "linux-arm64", - "linux-x64", - "windows-x64", - "windows-arm64", -) - -val prod = production -val checkCli by tasks.registering(CheckCliTask::class) { - description = "Verify CLI binaries exist before packaging" - dir.set(cliDir) - this.production.set(prod) - platforms.set(requiredPlatforms) -} - -// CLI binaries are verified only at packaging time (buildPlugin), not at -// processResources time, so that Kotlin compile and tests work without binaries. -// Wire checkCli to buildPlugin in the root build.gradle.kts instead. - dependencies { intellijPlatform { intellijIdea(libs.versions.intellij.platform) @@ -137,6 +122,7 @@ dependencies { implementation(project(":shared")) implementation(libs.okhttp) implementation(libs.okhttp.sse) + implementation(libs.commons.compress) implementation(libs.kotlinx.serialization.json) testImplementation(libs.okhttp.mockwebserver) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt index d6e1c67c088..275fca284ae 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt @@ -14,6 +14,7 @@ import ai.kilocode.rpc.dto.ConfigDto */ sealed class KiloAppState { data object Disconnected : KiloAppState() + data class Downloading(val percent: Int, val version: String, val platform: String) : KiloAppState() data object Connecting : KiloAppState() data class Loading(val progress: LoadProgress) : KiloAppState() data class MigrationRequired(val detection: LegacyMigrationDetection) : KiloAppState() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index e692df9ca68..5b3cefd0b7d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -146,7 +146,7 @@ class KiloBackendAppService private constructor( suspend fun connect() { mutex.withLock { val current = _appState.value - if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) return + if (current is KiloAppState.Ready || current is KiloAppState.Downloading || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) return ensureWatcher() connection.connect() } @@ -179,6 +179,7 @@ class KiloBackendAppService private constructor( ensureWatcher() connection.connect() } + is KiloAppState.Downloading, KiloAppState.Connecting, is KiloAppState.Loading -> Unit is KiloAppState.MigrationRequired -> { @@ -231,10 +232,11 @@ class KiloBackendAppService private constructor( when (_appState.value) { is KiloAppState.Ready -> return is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required") + is KiloAppState.Downloading, is KiloAppState.Loading, KiloAppState.Connecting -> { val state = withTimeoutOrNull(timeoutMs) { - appState.first { it !is KiloAppState.Loading && it !is KiloAppState.Connecting } + appState.first { it !is KiloAppState.Downloading && it !is KiloAppState.Loading && it !is KiloAppState.Connecting } } when (state) { is KiloAppState.Ready -> return @@ -292,7 +294,7 @@ class KiloBackendAppService private constructor( private suspend fun reconnect() { mutex.withLock { val current = _appState.value - if (current is KiloAppState.Ready || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) { + if (current is KiloAppState.Ready || current is KiloAppState.Downloading || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) { log.info("reconnect: already ${current::class.simpleName} — skipping") return } @@ -307,6 +309,7 @@ class KiloBackendAppService private constructor( connection.state.collect { next -> when (next) { ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected + is ConnectionState.Downloading -> _appState.value = KiloAppState.Downloading(next.percent, next.version, next.platform) ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting is ConnectionState.Connected -> { load() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index aa4300c4c42..bce451fc1e1 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -1,8 +1,8 @@ package ai.kilocode.backend.app +import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.cli.KiloBackendHttpClients import ai.kilocode.backend.cli.KiloCliDataParser -import ai.kilocode.backend.cli.CliServer import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi @@ -32,6 +32,7 @@ import java.util.concurrent.atomic.AtomicReference sealed class ConnectionState { data object Disconnected : ConnectionState() + data class Downloading(val percent: Int, val version: String, val platform: String) : ConnectionState() data object Connecting : ConnectionState() data class Connected(val port: Int, val password: String) : ConnectionState() data class Error(val message: String, val details: String? = null) : ConnectionState() @@ -144,7 +145,7 @@ class KiloConnectionService( } /** - * Kill the CLI process, re-extract the binary from JAR, and restart. + * Kill the CLI process, re-download the binary, and restart. * * Called under [KiloBackendAppService]'s mutex. */ @@ -153,7 +154,7 @@ class KiloConnectionService( teardown() log.info("reinstall: teardown complete — setting forceExtract flag") server.forceExtract = true - log.info("reinstall: spawning new CLI process (binary will be re-extracted)") + log.info("reinstall: spawning new CLI process (binary will be re-downloaded)") open() log.info("reinstall: open() returned — CLI process started with fresh binary") } @@ -190,7 +191,10 @@ class KiloConnectionService( setState(ConnectionState.Connecting) - val result = server.init() + val result = server.init( + onProgress = { item -> setState(ConnectionState.Downloading(item.percent, item.version, item.platform)) }, + onResolved = { setState(ConnectionState.Connecting) }, + ) if (result is CliServer.State.Error) { setState(ConnectionState.Error(result.message, result.details)) @@ -198,6 +202,7 @@ class KiloConnectionService( } val ready = result as CliServer.State.Ready + setState(ConnectionState.Connecting) port = ready.port password = ready.password diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index e124accafc4..51dbab97280 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -292,15 +292,15 @@ class KiloBackendSessionManager( title = s.title, version = s.version, time = SessionTimeDto( - created = s.time.created.toDouble(), - updated = s.time.updated.toDouble(), + created = s.time.created?.toDouble() ?: 0.0, + updated = s.time.updated?.toDouble() ?: 0.0, archived = s.time.archived, ), summary = s.summary?.let { SessionSummaryDto( - additions = it.additions.safeInt(), - deletions = it.deletions.safeInt(), - files = it.files.safeInt(), + additions = it.additions?.safeInt() ?: 0, + deletions = it.deletions?.safeInt() ?: 0, + files = it.files?.safeInt() ?: 0, ) }, ) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt index f36f917acb9..f0418d926a8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt @@ -13,8 +13,14 @@ interface CliServer { var forceExtract: Boolean fun process(): Process? - suspend fun init(): State + suspend fun init(onProgress: (CliDownload) -> Unit = {}, onResolved: () -> Unit = {}): State fun exited(proc: Process) fun stop() fun dispose() } + +data class CliDownload( + val percent: Int, + val version: String, + val platform: String, +) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index a8cfdb07a35..fe8dcb992cc 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -5,8 +5,6 @@ import ai.kilocode.backend.dev.KiloDevMode import ai.kilocode.log.KiloLog import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager -import com.intellij.openapi.util.SystemInfo -import com.intellij.util.system.CpuArch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -20,7 +18,7 @@ import java.util.concurrent.TimeUnit /** * Manages the Kilo CLI binary lifecycle. * - * Extracts the bundled CLI from JAR resources into IntelliJ's system directory, + * Downloads the pinned CLI into IntelliJ's system directory, * spawns `kilo serve --port 0`, and exposes the result as [State]. * * Concurrency is handled by the owning [KiloBackendAppService] — all public @@ -50,9 +48,10 @@ class KiloBackendCliManager( override fun process(): Process? = process - override suspend fun init(): CliServer.State { + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { return try { - val path = extractCli() + val path = resolveCli(onProgress) + onResolved() log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)") withTimeout(STARTUP_TIMEOUT_MS) { spawn(path) @@ -84,51 +83,11 @@ class KiloBackendCliManager( cleanup(proc, "stop()") } - private fun extractCli(): File { - val platform = platform() - val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo" - val target = File(PathManager.getSystemPath(), "kilo/bin/$exe") - val worker = File(target.parentFile, "kilo-sandbox-mutation-worker.js") - - if (forceExtract) { - log.info("Force re-extracting CLI resources under ${target.parentFile.absolutePath}") - if (target.exists()) target.delete() - if (worker.exists()) worker.delete() - forceExtract = false - } - - extractResource("cli/$platform/$exe", target, executable = true) - if (worker.exists()) worker.delete() - extractResource("cli/$platform/kilo-sandbox-mutation-worker.js", worker, executable = false) - return target - } - - private fun extractResource(resource: String, target: File, executable: Boolean) { - val loader = javaClass.classLoader - val url = loader.getResource(resource) - ?: throw IllegalStateException("CLI resource not found in JAR resources at $resource") - - val size = url.openConnection().contentLengthLong - if (size >= 0 && target.exists() && target.length() == size) { - log.info("CLI resource up-to-date at ${target.absolutePath}") - if (executable && !SystemInfo.isWindows) { - target.setExecutable(true) - } - return - } - - log.info("Extracting CLI resource to ${target.absolutePath}") - target.parentFile.mkdirs() - - url.openStream().use { input -> - target.outputStream().use { output -> - input.copyTo(output) - } - } - - if (executable && !SystemInfo.isWindows) { - target.setExecutable(true) - } + private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File { + val force = forceExtract + forceExtract = false + if (force) log.info("Force re-downloading CLI ${KiloProps.cliVersion()}") + return KiloCliDownloader(log = log).resolve(KiloProps.cliVersion(), force, onProgress) } // Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs). @@ -267,21 +226,6 @@ class KiloBackendCliManager( runCatching { proc.outputStream.close() }.onFailure { log.info("CLI stdin stream close skipped: ${it.message}") } } - private fun platform(): String { - val os = when { - SystemInfo.isMac -> "darwin" - SystemInfo.isLinux -> "linux" - SystemInfo.isWindows -> "windows" - else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}") - } - val arch = when (CpuArch.CURRENT) { - CpuArch.ARM64 -> "arm64" - CpuArch.X86_64 -> "x64" - else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}") - } - return "$os-$arch" - } - private fun generatePassword(): String { val bytes = ByteArray(32) SecureRandom().nextBytes(bytes) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt new file mode 100644 index 00000000000..22cdf292773 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -0,0 +1,129 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.log.KiloLog +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.util.SystemInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import java.io.File +import java.util.zip.ZipInputStream +import kotlin.math.roundToInt + +class KiloCliDownloader( + private val http: OkHttpClient = OkHttpClient(), + private val log: KiloLog = KiloLog.create(KiloCliDownloader::class.java), + private val root: File = File(PathManager.getSystemPath(), "kilo/cli"), + private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download", +) { + suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File = + withContext(Dispatchers.IO) { + val platform = KiloCliPlatform.current() + val dir = File(File(root, version), platform) + val exe = File(dir, "bin/${KiloCliPlatform.exe()}") + val done = File(dir, ".complete") + + if (force && dir.exists()) { + log.info("Deleting cached CLI $version under ${dir.absolutePath}") + dir.deleteRecursively() + } + + if (exe.isFile && done.isFile) { + log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") + if (!SystemInfo.isWindows) exe.setExecutable(true) + return@withContext exe + } + + log.info("Kilo CLI $version for $platform is not cached; downloading new release into ${dir.absolutePath}") + dir.mkdirs() + val ext = KiloCliPlatform.archive(platform) + val archive = File(dir, "kilo-$platform.$ext") + onProgress(CliDownload(0, version, platform)) + download(version, platform, ext, archive, onProgress) + log.info("Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)") + extract(archive, dir) + if (!exe.isFile) throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}") + if (!SystemInfo.isWindows) exe.setExecutable(true) + done.writeText("ok\n") + onProgress(CliDownload(100, version, platform)) + exe + } + + private fun download(version: String, platform: String, ext: String, file: File, onProgress: (CliDownload) -> Unit) { + val url = url(version, platform, ext) + log.info("Downloading Kilo CLI $version for $platform from $url") + val request = Request.Builder().url(url).build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException("Failed to download Kilo CLI $version for $platform: HTTP ${response.code}") + } + val body = response.body ?: throw IllegalStateException("Failed to download Kilo CLI $version: empty response body") + val total = body.contentLength() + var read = 0L + var last = 0 + file.outputStream().use { output -> + body.byteStream().use { input -> + val buffer = ByteArray(8 * 1024) + while (true) { + val n = input.read(buffer) + if (n < 0) break + output.write(buffer, 0, n) + read += n + if (total > 0) { + val pct = ((read.toDouble() / total.toDouble()) * 100.0).roundToInt().coerceIn(0, 100) + if (pct != last) { + last = pct + onProgress(CliDownload(pct, version, platform)) + } + } + } + } + } + } + } + + private fun extract(file: File, dir: File) { + log.info("Extracting Kilo CLI archive ${file.absolutePath}") + if (file.name.endsWith(".zip")) { + ZipInputStream(file.inputStream().buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + write(dir, entry.name, entry.isDirectory) { out -> zip.copyTo(out) } + zip.closeEntry() + } + } + return + } + + TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar -> + while (true) { + val entry = tar.nextEntry ?: break + write(dir, entry.name, entry.isDirectory) { out -> tar.copyTo(out) } + } + } + } + + private fun write(dir: File, name: String, directory: Boolean, copy: (java.io.OutputStream) -> Unit) { + val path = if (name.startsWith("bin/")) name else "bin/$name" + val target = File(dir, path).canonicalFile + val base = dir.canonicalFile + if (target != base && !target.path.startsWith(base.path + File.separator)) { + throw IllegalStateException("Archive entry escapes target directory: $name") + } + if (directory) { + target.mkdirs() + return + } + target.parentFile.mkdirs() + target.outputStream().use(copy) + if (!SystemInfo.isWindows && (target.name == "kilo" || target.name == "bwrap")) { + target.setExecutable(true) + } + } + + private fun url(version: String, platform: String, ext: String) = + "${baseUrl.trimEnd('/')}/v$version/kilo-$platform.$ext" +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt new file mode 100644 index 00000000000..86b494da6a9 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt @@ -0,0 +1,26 @@ +package ai.kilocode.backend.cli + +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.system.CpuArch + +internal object KiloCliPlatform { + fun current(): String { + val os = when { + SystemInfo.isMac -> "darwin" + SystemInfo.isLinux -> "linux" + SystemInfo.isWindows -> "windows" + else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}") + } + val arch = when (CpuArch.CURRENT) { + CpuArch.ARM64 -> "arm64" + CpuArch.X86_64 -> "x64" + else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}") + } + return "$os-$arch" + } + + fun exe(): String = if (SystemInfo.isWindows) "kilo.exe" else "kilo" + + fun archive(platform: String = current()): String = + if (platform.startsWith("linux-")) "tar.gz" else "zip" +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloProps.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloProps.kt new file mode 100644 index 00000000000..888a18f6ce4 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloProps.kt @@ -0,0 +1,16 @@ +package ai.kilocode.backend.cli + +import java.util.Properties + +object KiloProps { + private val props by lazy { + val stream = KiloProps::class.java.classLoader.getResourceAsStream("kilo.properties") + ?: throw IllegalStateException("kilo.properties resource not found") + stream.use { + Properties().apply { load(it) } + } + } + + fun cliVersion(): String = props.getProperty("cli.version") + ?: throw IllegalStateException("cli.version missing from kilo.properties") +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index b593cb5dbaf..5c2691a6b12 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -9,6 +9,7 @@ import ai.kilocode.backend.app.ConfigWarning import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.LoadProgress import ai.kilocode.backend.app.ProfileResult +import ai.kilocode.backend.cli.KiloProps import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.KiloAppRpcApi @@ -51,6 +52,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun health(): HealthDto = app.health() + override suspend fun cliVersion(): String = KiloProps.cliVersion() + override suspend fun retry() = app.retry() override suspend fun restart() = app.restart() @@ -109,6 +112,12 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { internal fun appStateDto(state: KiloAppState): KiloAppStateDto = when (state) { KiloAppState.Disconnected -> KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) + is KiloAppState.Downloading -> KiloAppStateDto( + status = KiloAppStatusDto.DOWNLOADING, + downloadPercent = state.percent, + downloadVersion = state.version, + downloadPlatform = state.platform, + ) KiloAppState.Connecting -> KiloAppStateDto(KiloAppStatusDto.CONNECTING) is KiloAppState.Loading -> KiloAppStateDto( status = KiloAppStatusDto.LOADING, @@ -143,12 +152,12 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto( organizations = p.profile.organizations.orEmpty().map { org -> ProfileOrganizationDto(id = org.id, name = org.name, role = org.role) }, - balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) }, + balance = p.balance?.let { ProfileBalanceDto(balance = it.balance ?: 0.0) }, kiloPass = p.kiloPass?.let { ProfileKiloPassDto( - currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd, - currentPeriodUsageUsd = it.currentPeriodUsageUsd, - currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd, + currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd ?: 0.0, + currentPeriodUsageUsd = it.currentPeriodUsageUsd ?: 0.0, + currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd ?: 0.0, nextBillingAt = it.nextBillingAt, ) }, diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt index 25bb6779ea5..b7c4f73f4c0 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt @@ -39,6 +39,7 @@ class KiloAppStateTest { @Test fun `KiloAppState sealed subtypes are distinct`() { assertIs(KiloAppState.Disconnected) + assertIs(KiloAppState.Downloading(42, "1.2.3", "darwin-arm64")) assertIs(KiloAppState.Connecting) assertIs(KiloAppState.Loading(LoadProgress())) assertIs(KiloAppState.Error("fail")) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index 7ba64fec23a..ee9e145a7bd 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -3,18 +3,21 @@ package ai.kilocode.backend.app import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.cli.CliServer +import ai.kilocode.backend.cli.CliDownload import ai.kilocode.backend.rpc.appStateDto import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull @@ -67,7 +70,8 @@ class KiloBackendAppServiceTest { override fun process(): Process? = null - override suspend fun init(): CliServer.State { + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { + onResolved() if (starts.getAndIncrement() == 0) { val socket = ServerSocket(0) srv = socket @@ -102,6 +106,46 @@ class KiloBackendAppServiceTest { assertNotNull(ready.data.notifications) } + @Test + fun `download progress maps to app state before ready`() = runBlocking { + val resolved = CompletableDeferred() + val ready = CompletableDeferred() + val server = object : CliServer { + override var forceExtract = false + override fun process(): Process? = null + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { + onProgress(CliDownload(37, "1.2.3", "darwin-arm64")) + resolved.await() + onResolved() + ready.await() + return CliServer.State.Ready(mock.start(), mock.password) + } + override fun exited(proc: Process) {} + override fun stop() {} + override fun dispose() {} + } + val svc = KiloBackendAppService.create(scope, server, log) + val job = scope.launch { svc.connect() } + + val downloading = withTimeout(5_000) { + svc.appState.first { it is KiloAppState.Downloading } + } + assertEquals(KiloAppState.Downloading(37, "1.2.3", "darwin-arm64"), downloading) + val dto = appStateDto(downloading) + assertEquals(37, dto.downloadPercent) + assertEquals("1.2.3", dto.downloadVersion) + assertEquals("darwin-arm64", dto.downloadPlatform) + + resolved.complete(Unit) + withTimeout(5_000) { + svc.appState.first { it == KiloAppState.Connecting } + } + + ready.complete(Unit) + ready(svc) + job.join() + } + @Test fun `shutdown for unload clears runtime and disposes server once`() = runBlocking { val server = FakeCliServer(mock) @@ -389,7 +433,7 @@ class KiloBackendAppServiceTest { val failing = object : ai.kilocode.backend.cli.CliServer { override var forceExtract = false override fun process(): Process? = null - override suspend fun init() = ai.kilocode.backend.cli.CliServer.State.Error( + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit) = ai.kilocode.backend.cli.CliServer.State.Error( message = "CLI startup failed", details = "stderr: missing dependency", ) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt index 335b91e2e87..34cef9fecd2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt @@ -1,12 +1,14 @@ package ai.kilocode.backend.app import ai.kilocode.backend.cli.CliServer +import ai.kilocode.backend.cli.CliDownload import ai.kilocode.backend.app.ConnectionState import ai.kilocode.backend.app.KiloConnectionService import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.log.KiloLog +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers @@ -78,6 +80,45 @@ class KiloConnectionServiceTest { assertTrue(svc.api != null) } + @Test + fun `connect reports downloading then connecting before ready`() = runBlocking { + val resolved = CompletableDeferred() + val ready = CompletableDeferred() + val server = object : CliServer { + override var forceExtract = false + override fun process(): Process? = null + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { + onProgress(CliDownload(42, "1.2.3", "darwin-arm64")) + resolved.await() + onResolved() + ready.await() + return CliServer.State.Ready(mock.start(), mock.password) + } + override fun exited(proc: Process) {} + override fun stop() {} + override fun dispose() {} + } + val svc = KiloConnectionService(scope, server, {}, log) + val job = scope.launch { svc.connect() } + + val downloading = withTimeout(5_000) { + svc.state.first { it is ConnectionState.Downloading } + } + assertEquals(ConnectionState.Downloading(42, "1.2.3", "darwin-arm64"), downloading) + + resolved.complete(Unit) + withTimeout(5_000) { + svc.state.first { it == ConnectionState.Connecting } + } + + ready.complete(Unit) + mock.awaitSseConnection() + withTimeout(5_000) { + svc.state.first { it is ConnectionState.Connected } + } + job.join() + } + @Test fun `SSE events are emitted`() = runBlocking { val svc = KiloConnectionService(scope, fake, {}, log) @@ -189,7 +230,7 @@ class KiloConnectionServiceTest { val failing = object : CliServer { override var forceExtract = false override fun process(): Process? = null - override suspend fun init() = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2") + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit) = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2") override fun exited(proc: Process) {} override fun stop() {} override fun dispose() {} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt new file mode 100644 index 00000000000..a62a4bb387a --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -0,0 +1,104 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.backend.testing.TestLog +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class KiloCliDownloaderTest { + @TempDir + lateinit var dir: File + + @Test + fun `downloads extracts and caches pinned cli`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive() + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(bytes))) + val seen = mutableListOf() + val log = TestLog() + val cli = KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + ).resolve("1.2.3", onProgress = { seen.add(it) }) + + assertTrue(cli.isFile) + assertEquals(File(File(dir, "1.2.3"), KiloCliPlatform.current()).absolutePath, cli.parentFile.parentFile.absolutePath) + assertEquals("#!/bin/sh\n", cli.readText()) + assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile) + assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path) + assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first()) + assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() }) + assertContains(log.messages, "INFO: Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} is not cached; downloading new release into ${cli.parentFile.parentFile.absolutePath}") + + val cachedProgress = mutableListOf() + val cached = KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + ).resolve("1.2.3", onProgress = { cachedProgress.add(it) }) + assertEquals(cli.absolutePath, cached.absolutePath) + assertEquals(1, server.requestCount) + assertTrue(cachedProgress.isEmpty()) + assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}") + + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(bytes))) + val forced = KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + ).resolve("1.2.3", force = true) + assertEquals(cli.absolutePath, forced.absolutePath) + assertEquals(2, server.requestCount) + } + } + + private fun archive(): ByteArray { + val files = mapOf( + "bin/${KiloCliPlatform.exe()}" to "#!/bin/sh\n".toByteArray(), + "bin/kilo-sandbox-mutation-worker.js" to "worker\n".toByteArray(), + ) + if (KiloCliPlatform.archive() == "zip") return zip(files) + return tar(files) + } + + private fun zip(files: Map): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + files.forEach { entry -> + zip.putNextEntry(ZipEntry(entry.key)) + zip.write(entry.value) + zip.closeEntry() + } + } + return out.toByteArray() + } + + private fun tar(files: Map): ByteArray { + val out = ByteArrayOutputStream() + GzipCompressorOutputStream(out).use { gzip -> + TarArchiveOutputStream(gzip).use { tar -> + files.forEach { entry -> + val item = TarArchiveEntry(entry.key) + item.size = entry.value.size.toLong() + tar.putArchiveEntry(item) + tar.write(entry.value) + tar.closeArchiveEntry() + } + } + } + return out.toByteArray() + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt index aa29477ff96..e6eeca253d4 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt @@ -1,6 +1,7 @@ package ai.kilocode.backend.testing import ai.kilocode.backend.cli.CliServer +import ai.kilocode.backend.cli.CliDownload /** * Fake [CliServer] that delegates to a [MockCliServer] instead of @@ -20,8 +21,10 @@ class FakeCliServer(private val mock: MockCliServer) : CliServer { override fun process(): Process? = null - override suspend fun init(): CliServer.State = - CliServer.State.Ready(mock.start(), mock.password) + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { + onResolved() + return CliServer.State.Ready(mock.start(), mock.password) + } override fun exited(proc: Process) {} diff --git a/packages/kilo-jetbrains/build-tasks/build.gradle.kts b/packages/kilo-jetbrains/build-tasks/build.gradle.kts index d2882538364..1fd055a01d3 100644 --- a/packages/kilo-jetbrains/build-tasks/build.gradle.kts +++ b/packages/kilo-jetbrains/build-tasks/build.gradle.kts @@ -8,6 +8,7 @@ repositories { dependencies { implementation(libs.kotlinx.serialization.json) + implementation(libs.commons.compress) testImplementation(kotlin("test")) } diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt index ad42d56f559..1c25aaec60f 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt @@ -7,8 +7,8 @@ import org.gradle.api.Project * `id("build-tasks")` resolves in `backend/build.gradle.kts`. * * The real value lives in the custom task classes this composite build - * provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask], - * [PrepareLocalCliTask], and [CheckCliTask]. + * provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask], and + * [GenerateOpenApiSpecTask]. */ class BuildTasksPlugin : Plugin { override fun apply(target: Project) {} diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt deleted file mode 100644 index dffe15e5169..00000000000 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt +++ /dev/null @@ -1,59 +0,0 @@ -import org.gradle.api.DefaultTask -import org.gradle.api.GradleException -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction -import java.io.File - -/** - * Verify that CLI binaries exist before packaging the plugin. - * In production mode, all platform binaries must be present. - * In dev mode, only the current platform binary is required. - * - * CLI binaries must be prepared separately before packaging: - * Local: bun run build (from packages/kilo-jetbrains/) - * Production: bun run build:production - */ -abstract class CheckCliTask : DefaultTask() { - @get:InputDirectory - @get:Optional - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val dir: DirectoryProperty - - @get:Input - abstract val production: Property - - @get:Input - abstract val platforms: ListProperty - - @TaskAction - fun run() { - val resolved = dir.get().asFile - 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 (production.get()) { - val missing = platforms.get().filter { platform -> - val d = File(resolved, platform) - val exe = if (platform.startsWith("windows")) "kilo.exe" else "kilo" - !File(d, 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." - ) - } - } - } -} diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt index f594d0f06ba..aebd99feee4 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -1,50 +1,32 @@ +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty -import org.gradle.api.tasks.InputDirectory +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction -import javax.inject.Inject import org.gradle.process.ExecOperations import java.io.ByteArrayOutputStream +import java.io.File +import java.net.HttpURLConnection +import java.net.URI +import java.util.zip.ZipInputStream +import javax.inject.Inject /** - * Generates the CLI OpenAPI spec into the build directory so the JetBrains - * Gradle build is self-contained and does not mutate the tracked - * packages/sdk/openapi.json. - * - * Runs `bun dev generate` from the opencode package directory and captures - * stdout to [spec]. stderr is captured separately and included in the error - * message on failure. - * - * Gradle up-to-date tracking is scoped to [serverSrcDir] (the opencode server - * source) to avoid busting the cache on unrelated changes to dist/, node_modules/, - * etc. + * Generates the CLI OpenAPI spec from the pinned release binary. */ abstract class GenerateOpenApiSpecTask : DefaultTask() { + @get:Input + abstract val cliVersion: Property - /** - * The server source directory inside the opencode package — the only files - * that affect the OpenAPI output. Scoped to `src/server/` to avoid busting - * the Gradle up-to-date check on unrelated file changes (dist/, node_modules/). - */ - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val serverSrcDir: DirectoryProperty - - /** - * Root of the `packages/opencode/` package — the working directory for bun. - * Marked @Internal because it is not itself a Gradle input; only [serverSrcDir] - * (a subdirectory) participates in up-to-date checking. - */ @get:Internal - abstract val opencodeDir: DirectoryProperty + abstract val cacheDir: DirectoryProperty - /** Destination file for the generated openapi.json. */ @get:OutputFile abstract val spec: RegularFileProperty @@ -53,52 +35,122 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { @TaskAction fun run() { + val kilo = resolve() val out = ByteArrayOutputStream() val err = ByteArrayOutputStream() val result = exec.exec { - workingDir = opencodeDir.get().asFile - commandLine(findBun(), "run", "--conditions=browser", "./src/index.ts", "generate") + commandLine(kilo.absolutePath, "generate") standardOutput = out errorOutput = err isIgnoreExitValue = true } if (result.exitValue != 0) { throw GradleException( - "bun dev generate failed with exit code ${result.exitValue}.\n" + - err.toString(Charsets.UTF_8).take(2000) + "kilo generate failed with exit code ${result.exitValue}.\n" + + err.toString(Charsets.UTF_8).take(2000) ) } val json = out.toString(Charsets.UTF_8) if (!json.trimStart().startsWith("{")) { throw GradleException( - "bun dev generate did not produce JSON.\n" + - "stdout: ${json.take(200)}\n" + - "stderr: ${err.toString(Charsets.UTF_8).take(500)}" + "kilo generate did not produce JSON.\n" + + "stdout: ${json.take(200)}\n" + + "stderr: ${err.toString(Charsets.UTF_8).take(500)}" ) } spec.get().asFile.also { it.parentFile.mkdirs() }.writeText(json) } - private fun findBun(): String { - val which = runCatching { - ProcessBuilder("which", "bun") - .redirectErrorStream(true) - .start() - .inputStream.bufferedReader().readLine()?.trim() - }.getOrNull() - if (which != null && java.io.File(which).isFile) return which + private fun resolve(): File { + val version = cliVersion.get() + val platform = platform() + val ext = if (platform.startsWith("linux-")) "tar.gz" else "zip" + val dir = cacheDir.dir(version).map { it.dir(platform) }.get().asFile + val exe = File(dir, "bin/${exe()}") + val done = File(dir, ".complete") + if (exe.isFile && done.isFile) { + if (!windows()) exe.setExecutable(true) + return exe + } + dir.mkdirs() + val archive = File(dir, "kilo-$platform.$ext") + download("https://github.com/Kilo-Org/kilocode/releases/download/v$version/kilo-$platform.$ext", archive) + extract(archive, dir) + if (!exe.isFile) throw GradleException("Downloaded CLI archive did not contain bin/${exe()}") + if (!windows()) exe.setExecutable(true) + done.writeText("ok\n") + return exe + } + + private fun download(url: String, file: File) { + logger.lifecycle("Downloading pinned Kilo CLI from $url") + val conn = URI(url).toURL().openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 120_000 + conn.instanceFollowRedirects = true + try { + val code = conn.responseCode + if (code !in 200..299) throw GradleException("Failed to download pinned Kilo CLI: HTTP $code from $url") + conn.inputStream.use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } + } finally { + conn.disconnect() + } + } - val home = System.getProperty("user.home") - val candidates = listOf( - "$home/.bun/bin/bun", - "/opt/homebrew/bin/bun", - "/usr/local/bin/bun", - "$home/.nvm/current/bin/bun", - ) - for (path in candidates) { - val f = java.io.File(path) - if (f.isFile && f.canExecute()) return f.absolutePath + private fun extract(file: File, dir: File) { + if (file.name.endsWith(".zip")) { + ZipInputStream(file.inputStream().buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + write(dir, entry.name, entry.isDirectory) { out -> zip.copyTo(out) } + zip.closeEntry() + } + } + return + } + TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar -> + while (true) { + val entry = tar.nextEntry ?: break + write(dir, entry.name, entry.isDirectory) { out -> tar.copyTo(out) } + } } - return "bun" } + + private fun write(dir: File, name: String, directory: Boolean, copy: (java.io.OutputStream) -> Unit) { + val path = if (name.startsWith("bin/")) name else "bin/$name" + val target = File(dir, path).canonicalFile + val base = dir.canonicalFile + if (target != base && !target.path.startsWith(base.path + File.separator)) { + throw GradleException("Archive entry escapes target directory: $name") + } + if (directory) { + target.mkdirs() + return + } + target.parentFile.mkdirs() + target.outputStream().use(copy) + if (!windows() && (target.name == "kilo" || target.name == "bwrap")) target.setExecutable(true) + } + + private fun platform(): String { + val os = System.getProperty("os.name").lowercase() + val name = when { + os.contains("mac") || os.contains("darwin") -> "darwin" + os.contains("linux") -> "linux" + os.contains("windows") -> "windows" + else -> throw GradleException("Unsupported OS: ${System.getProperty("os.name")}") + } + val arch = when (System.getProperty("os.arch").lowercase()) { + "aarch64", "arm64" -> "arm64" + "x86_64", "amd64" -> "x64" + else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}") + } + return "$name-$arch" + } + + private fun exe() = if (windows()) "kilo.exe" else "kilo" + + private fun windows() = System.getProperty("os.name").lowercase().contains("windows") } diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt deleted file mode 100644 index dc1501154f7..00000000000 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt +++ /dev/null @@ -1,139 +0,0 @@ -import org.gradle.api.DefaultTask -import org.gradle.api.GradleException -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.TaskAction -import org.gradle.process.ExecOperations -import org.gradle.work.DisableCachingByDefault -import java.io.File -import javax.inject.Inject - -/** - * Prepare the local-platform CLI binary for JetBrains backend runs. - * - * Runs `bun run build --prepare-cli` from the [root] project directory when - * the expected current-platform binary is absent from [dir]. Skips - * immediately when the binary already exists. - * - * Bun is found in the following priority order: - * 1. Explicit `-Pkilo.bun.path=...` Gradle property (forwarded via [bunPath]) - * 2. `BUN_EXE` environment variable (absolute path to the bun executable) - * 3. `BUN_INSTALL/bin/bun` (from `BUN_INSTALL` environment variable) - * 4. Each entry on `PATH` - * 5. Common install locations (`~/.bun/bin`, `/opt/homebrew/bin`, `/usr/local/bin`) - * - * After Bun finishes, the task verifies the expected binary exists so that Rosetta / architecture - * mismatches surface with a clear error message. - */ -@DisableCachingByDefault(because = "Local developer bootstrap task that shells out to Bun") -abstract class PrepareLocalCliTask : DefaultTask() { - @get:Internal - abstract val root: DirectoryProperty - - @get:OutputDirectory - abstract val dir: DirectoryProperty - - @get:Input - @get:Optional - abstract val bunPath: Property - - @get:Inject - abstract val exec: ExecOperations - - @TaskAction - fun run() { - val expected = binary() - if (expected.exists()) { - logger.lifecycle("CLI binary already exists at ${expected.absolutePath}") - return - } - - val bun = findBun() - logger.lifecycle("Preparing local CLI binary with ${bun.absolutePath}") - val result = exec.exec { - workingDir = root.get().asFile - executable = bun.absolutePath - args("run", "build", "--prepare-cli") - isIgnoreExitValue = true - } - - if (result.exitValue != 0) { - throw GradleException("bun run build --prepare-cli failed with exit code ${result.exitValue}") - } - - if (!expected.exists()) { - throw GradleException( - "Expected CLI binary was not created at ${expected.absolutePath}. " + - "This can happen if Bun and Gradle are running under different architectures." - ) - } - } - - private fun binary(): File { - return File(File(dir.get().asFile, platform()), exe()) - } - - private fun platform(): String { - return "${os()}-${arch()}" - } - - private fun os(): String { - val name = System.getProperty("os.name").lowercase() - if ("mac" in name || "darwin" in name) return "darwin" - if ("linux" in name) return "linux" - if ("windows" in name) return "windows" - throw GradleException("Unsupported OS for JetBrains CLI binary: $name") - } - - private fun arch(): String { - return when (val value = System.getProperty("os.arch").lowercase()) { - "aarch64", "arm64" -> "arm64" - "x86_64", "amd64" -> "x64" - else -> throw GradleException("Unsupported architecture for JetBrains CLI binary: $value") - } - } - - private fun exe(): String { - if (os() == "windows") return "kilo.exe" - return "kilo" - } - - private fun findBun(): File { - val configured = bunPath.orNull?.trim()?.takeIf { it.isNotEmpty() } - if (configured != null) return requireBun(File(configured)) - - val names = if (os() == "windows") listOf("bun.exe", "bun.cmd", "bun.bat") else listOf("bun") - val candidates = mutableListOf() - - System.getenv("BUN_EXE")?.trim()?.takeIf { it.isNotEmpty() }?.let { candidates.add(File(it)) } - System.getenv("BUN_INSTALL")?.trim()?.takeIf { it.isNotEmpty() }?.let { install -> - candidates.addAll(names.map { File(File(install, "bin"), it) }) - } - System.getenv("PATH")?.split(File.pathSeparator)?.forEach { entry -> - candidates.addAll(names.map { File(entry, it) }) - } - - val home = System.getProperty("user.home") - candidates.addAll(names.map { File(File(home, ".bun/bin"), it) }) - candidates.addAll(names.map { File("/opt/homebrew/bin", it) }) - candidates.addAll(names.map { File("/usr/local/bin", it) }) - - return candidates.firstOrNull(::usable) - ?: throw GradleException("Could not find Bun. Install Bun or pass -Pkilo.bun.path=/absolute/path/to/bun.") - } - - private fun requireBun(file: File): File { - if (usable(file)) return file - throw GradleException("Configured Bun executable is not usable: ${file.absolutePath}") - } - - private fun usable(file: File): Boolean { - if (!file.isFile) return false - if (os() == "windows") return true - return file.canExecute() - } -} diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 50c07ca0044..0b0a136e627 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -221,7 +221,6 @@ tasks { runIdeBackend { splitModeServerPort.set(splitPort) - dependsOn(":backend:prepareLocalCli") dependsOn(":backend:processResources") } @@ -231,17 +230,12 @@ tasks { runIdeSplitMode { splitModeServerPort.set(splitPort) - dependsOn(":backend:prepareLocalCli") dependsOn(":backend:processResources") } } -project(":backend").tasks.named("processResources") { - mustRunAfter(":backend:prepareLocalCli") -} - // Compile-only typecheck: verifies Kotlin compiles (including generated API client) -// without running processResources, CLI binary prep, or buildPlugin. +// without running buildPlugin. tasks.register("typecheck") { dependsOn( ":shared:compileKotlin", @@ -252,12 +246,6 @@ tasks.register("typecheck") { ) } -// CLI binaries must be present before packaging. Wire the check here (not in -// :backend:processResources) so compile/test tasks work without CLI binaries. -tasks.named("buildPlugin") { - dependsOn(":backend:checkCli") -} - tasks.named("runIde") { dependsOn(":backend:processResources") jvmArgumentProviders += CommandLineArgumentProvider { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index d18532b7394..2ef806cfa98 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -131,17 +131,29 @@ class KiloAppService internal constructor( cs.launch { reinstall() } } - /** Fetch the CLI version and cache it. Call once after connection is established. */ - fun fetchVersionAsync() { + suspend fun cliVersion(): String? = try { + call { cliVersion() } + } catch (e: Exception) { + LOG.warn("cliVersion failed", e) + null + } + + /** Fetch the pinned CLI version and cache it. */ + fun fetchVersionAsync(done: (String?) -> Unit = {}) { + version?.let { + done(it) + return + } cs.launch { - LOG.info("fetchVersion: requesting health check") - val dto = health() - if (dto == null) { - LOG.warn("fetchVersion: health check returned null — version not available") + LOG.info("fetchVersion: requesting CLI version") + val text = cliVersion() + if (text == null) { + done(null) return@launch } - version = dto.version - LOG.info("fetchVersion: CLI version is ${dto.version}") + version = text + done(text) + LOG.info("fetchVersion: CLI version is $text") } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 2e7945cd4ef..08e8faaede7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -485,8 +485,14 @@ class SessionUi( activity = { manager?.activity() ?: sessions.activity() }, titles = { manager?.titles().orEmpty() }, timers = timers, + cliVersion = app.version, ) empty = panel + app.fetchVersionAsync { version -> + ApplicationManager.getApplication().invokeLater { + if (!disposed && empty === panel) panel.setCliVersion(version) + } + } scroll.show(panel.view) } @@ -497,6 +503,7 @@ class SessionUi( is SessionControllerEvent.AppChanged -> { prompt.setReady(controller.model.isReady()) + empty?.setCliVersion(app.version) } is SessionControllerEvent.WorkspaceChanged -> { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index f7b3e9a5400..e50b9c760a5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -1833,6 +1833,14 @@ class SessionController( ) } + if (app.status == KiloAppStatusDto.DOWNLOADING) { + return SessionControllerEvent.ConnectionChanged.ShowDownloading( + app.downloadPercent ?: 0, + app.downloadVersion, + app.downloadPlatform, + ) + } + if (workspace.status == KiloWorkspaceStatusDto.ERROR) { return SessionControllerEvent.ConnectionChanged.ShowError( KiloBundle.message("session.connection.error.workspace"), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt index 50f644fe327..c6a4985b28c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt @@ -63,6 +63,10 @@ sealed class SessionControllerEvent { override fun toString() = "ConnectionChanged connecting" } + data class ShowDownloading(val percent: Int, val version: String? = null, val platform: String? = null) : ConnectionChanged() { + override fun toString() = "ConnectionChanged downloading $percent" + } + data class ShowError(val summary: String, val detail: String?, val source: String = "app") : ConnectionChanged() { override fun toString() = "ConnectionChanged error $source" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index ed563da8844..2aaf2acf711 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -120,6 +120,8 @@ class ConnectionPanel( is SessionControllerEvent.ConnectionChanged.ShowConnecting -> showConnecting() + is SessionControllerEvent.ConnectionChanged.ShowDownloading -> showDownloading(event.percent, event.version, event.platform) + is SessionControllerEvent.ConnectionChanged.ShowError -> { showError(event.summary, event.detail) showPanel() @@ -145,6 +147,22 @@ class ConnectionPanel( showPanel() } + private fun showDownloading(percent: Int, version: String?, platform: String?) { + label.foreground = UiStyle.Colors.weak() + val pct = percent.coerceIn(0, 100) + label.text = if (version != null && platform != null) { + KiloBundle.message("session.connection.downloading.version", version, platform, pct) + } else { + KiloBundle.message("session.connection.downloading", pct) + } + detail = null + expanded = false + toggle.isVisible = false + retry.isVisible = false + renderDetails() + showPanel() + } + private fun showError(text: String, detail: String?) { label.foreground = UiStyle.Colors.errorLabelForeground() label.text = text diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt index f277884c261..c8b7a7cdc5d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -55,6 +55,7 @@ class EmptySessionPanel( private val titles: () -> Map = { emptyMap() }, private val browse: (String) -> Unit = BrowserUtil::browse, private val timers: UiTimerSource = UiTimers, + cliVersion: String? = null, ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { val view: Align = align(HAlign.CENTER, VAlign.CENTER) @@ -73,6 +74,13 @@ class EmptySessionPanel( setAllowAutoWrapping(true) } + private val versionLabel = JBLabel().apply { + foreground = UIUtil.getContextHelpForeground() + font = com.intellij.util.ui.JBFont.small() + horizontalAlignment = JBLabel.CENTER + isVisible = false + } + private val description = object : BorderLayoutPanel() { override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() @@ -112,11 +120,15 @@ class EmptySessionPanel( ).apply { horizontalAlignment = JBLabel.CENTER } + val logoBlock = Stack.vertical(gap = UiStyle.Gap.xs()) + .next(logo) + .next(versionLabel) val header = BorderLayoutPanel(0, gap).apply { isOpaque = false - add(logo, BorderLayout.NORTH) + add(logoBlock, BorderLayout.NORTH) add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) } + setCliVersion(cliVersion) val south = BorderLayoutPanel().apply { isOpaque = false @@ -158,6 +170,15 @@ class EmptySessionPanel( internal fun feedbackContent(open: (String) -> Unit = {}): JComponent = EmptySessionFeedback.content(open) + @RequiresEdt + fun setCliVersion(version: String?) { + val text = version?.takeIf { it.isNotBlank() } + versionLabel.text = text?.let { KiloBundle.message("session.empty.cliVersion", it) }.orEmpty() + versionLabel.isVisible = text != null + revalidate() + repaint() + } + internal fun feedbackUrls() = EmptySessionFeedback.urls() internal fun showHistoryBorderPainted() = historyButton.isBorderPainted diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt index 6324247efa3..e71ce3b7e33 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt @@ -181,7 +181,7 @@ internal class LoggedOutProfileUi( } private fun resolveMode(status: KiloAppStatusDto, login: LoginState): OutMode = when { - status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.CONNECTING || status == KiloAppStatusDto.MIGRATION_REQUIRED -> OutMode.CONNECTING + status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.DOWNLOADING || status == KiloAppStatusDto.CONNECTING || status == KiloAppStatusDto.MIGRATION_REQUIRED -> OutMode.CONNECTING status == KiloAppStatusDto.ERROR -> OutMode.APP_ERROR login is LoginState.Initiating -> OutMode.INITIATING login is LoginState.Pending -> OutMode.AUTH diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt index 8e942bdd99d..eaeed04cd66 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt @@ -163,7 +163,7 @@ internal class ProfileUi( val p = prof // When loading/connecting and already showing the logged-in card, stay on it to // avoid focus loss during reconnects, initial loads, and org switches. - val transientLoad = s == KiloAppStatusDto.CONNECTING || s == KiloAppStatusDto.LOADING || s == KiloAppStatusDto.MIGRATION_REQUIRED + val transientLoad = s == KiloAppStatusDto.DOWNLOADING || s == KiloAppStatusDto.CONNECTING || s == KiloAppStatusDto.LOADING || s == KiloAppStatusDto.MIGRATION_REQUIRED if (transientLoad && shown == Card.LOGGED_IN) return Card.LOGGED_IN return when { s == KiloAppStatusDto.DISCONNECTED || transientLoad -> Card.LOGGED_OUT diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index c64398d56ec..00285f7666e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -1,6 +1,8 @@ common.delete=Delete session.connection.connecting=Loading... +session.connection.downloading=Downloading Kilo CLI… {0}% +session.connection.downloading.version=Downloading Kilo CLI v{0} ({1})… {2}% session.connection.error.app=Connection failed session.connection.error.workspace=Workspace loading failed session.connection.error.unknown=Unknown error @@ -10,6 +12,7 @@ session.connection.warning.config=Configuration warnings notification.group.kilo=Kilo Code session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. +session.empty.cliVersion=Kilo CLI v{0} session.account.balance=Balance: {0} session.account.switcher=Switch account session.empty.loading=Loading... diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index deb5a8d94e8..f969235bb97 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.controller.SessionControllerEvent import ai.kilocode.client.session.controller.SessionControllerTestBase @@ -35,6 +36,19 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertFalse(panel.retryVisible()) } + fun `test downloading hides retry and details`() { + edt { + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowDownloading(42, "1.2.3", "darwin-arm64")) + } + + assertTrue(panel.isVisible) + assertEquals(KiloBundle.message("session.connection.downloading.version", "1.2.3", "darwin-arm64", 42), panel.summaryText()) + assertEquals("", panel.detailsText()) + assertFalse(panel.toggleVisible()) + assertFalse(panel.detailsVisible()) + assertFalse(panel.retryVisible()) + } + fun `test app error starts collapsed and expands details`() { edt { panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index d0c6f0b0eab..6f362a0449c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -138,6 +138,23 @@ class EmptySessionPanelTest : BasePlatformTestCase() { ) } + fun `test cli version line updates`() { + val panel = panel() + val text = KiloBundle.message("session.empty.cliVersion", "1.2.3") + + panel.setCliVersion("1.2.3") + val label = UIUtil.uiTraverser(panel) + .filter(JBLabel::class.java) + .first { it.text == text } + + assertTrue(label.isVisible) + + panel.setCliVersion(null) + + assertEquals("", label.text) + assertFalse(label.isVisible) + } + fun `test selecting recent session does not open it`() { val panel = panel(listOf(session("ses_1"), session("ses_2"))) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index fc6cb674cae..546ba6e55ff 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -31,6 +31,7 @@ class FakeAppRpcApi : KiloAppRpcApi { val state = MutableStateFlow(KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)) var health = HealthDto(healthy = true, version = "1.0.0") + var cliVersion = "1.0.0" var models = ModelStateDto() val selections = mutableListOf() val cleared = mutableListOf() @@ -67,6 +68,11 @@ class FakeAppRpcApi : KiloAppRpcApi { return health } + override suspend fun cliVersion(): String { + assertNotEdt("cliVersion") + return cliVersion + } + override suspend fun retry() { assertNotEdt("retry") retries += 1 diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 2cfb8872e11..15b48416fe5 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -11,6 +11,7 @@ detekt = "1.23.8" commonmark = "0.28.0" zxing = "3.5.3" changelog = "2.5.0" +commons-compress = "1.28.0" [libraries] commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } @@ -23,6 +24,7 @@ okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version. kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } +commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commons-compress" } [plugins] detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/packages/kilo-jetbrains/script/build-version.sh b/packages/kilo-jetbrains/script/build-version.sh index ff344202405..4cd0c7663d4 100755 --- a/packages/kilo-jetbrains/script/build-version.sh +++ b/packages/kilo-jetbrains/script/build-version.sh @@ -6,7 +6,7 @@ usage() { Usage: $0 [options] Builds the JetBrains plugin for a version without creating or validating a git tag. -By default this runs a clean build, prepares production CLI binaries, signs the ZIP, and verifies it. +By default this runs a clean build, signs the ZIP, and verifies it. Version: x.y.z or x.y.z-rc.n, with an optional leading v. @@ -75,7 +75,6 @@ fi script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" plugin="$(cd "${script}/.." && pwd)" -cli="$(cd "${plugin}/../opencode" && pwd)" secrets="${HOME}/.secrets/jetbrains" chain="${secrets}/chain.crt" key="${secrets}/private.pem" @@ -87,11 +86,6 @@ if [[ ! -d "$plugin" ]]; then exit 1 fi -if [[ ! -f "${cli}/package.json" ]]; then - echo "Expected CLI package at $cli" >&2 - exit 1 -fi - if [[ "$sign" == "1" ]]; then for file in "$chain" "$key" "$pass"; do if [[ ! -s "$file" ]]; then @@ -117,8 +111,6 @@ if [[ "$clean" == "1" ]]; then ./gradlew clean fi -rm -rf "${cli}/dist" -KILO_VERSION="$version" KILO_CHANNEL=rc bun "${plugin}/script/build.ts" --production --prepare-cli ./gradlew buildPlugin -Pproduction=true -Pkilo.version="$version" -Pkilo.channel=eap if [[ "$sign" == "1" ]]; then diff --git a/packages/kilo-jetbrains/script/build.ts b/packages/kilo-jetbrains/script/build.ts index 666040cc6cb..27cc3cb49a3 100644 --- a/packages/kilo-jetbrains/script/build.ts +++ b/packages/kilo-jetbrains/script/build.ts @@ -4,124 +4,24 @@ * Build the Kilo JetBrains plugin. * * Usage: - * bun script/build.ts # Local build — only current platform binary required - * bun script/build.ts --production # Production build — all 6 platform binaries required - * bun script/build.ts --prepare-cli # Only prepare local CLI resources for Gradle + * bun script/build.ts # Local plugin build + * bun script/build.ts --production # Production plugin build * - * Steps: - * 1. Builds CLI binaries (or uses prebuilt ones from dist/). - * Local: builds only current platform (--single). - * Production: builds all platforms. - * 2. Copies them and the Kilo sandbox mutation worker into backend/build/generated/cli/cli/{os}/ - * so they end up inside the backend jar at /cli/{os}/. - * 3. Invokes Gradle to build the plugin. + * The JetBrains plugin no longer bundles CLI binaries. Runtime downloads the + * pinned release from GitHub using packages/kilo-jetbrains/package.json version. */ import { $ } from "bun" -import { join, relative } from "node:path" -import { existsSync, mkdirSync, chmodSync, cpSync, rmSync } from "node:fs" +import { join } from "node:path" const production = process.argv.includes("--production") -const cliOnly = process.argv.includes("--prepare-cli") - const root = join(import.meta.dir, "..") -const packages = join(root, "..") -const opencodeDir = join(packages, "opencode") -const distDir = join(opencodeDir, "dist") -const cliDir = join(root, "backend", "build", "generated", "cli", "cli") - -/** All desktop platforms. */ -const platforms = [ - { os: "darwin-arm64", exe: "kilo" }, - { os: "darwin-x64", exe: "kilo" }, - { os: "linux-arm64", exe: "kilo" }, - { os: "linux-x64", exe: "kilo" }, - { os: "windows-x64", exe: "kilo.exe" }, - { os: "windows-arm64", exe: "kilo.exe" }, -] as const - -function localPlatformTag(): string { - const os = process.platform === "win32" ? "windows" : process.platform - return `${os}-${process.arch}` -} +const gradlew = process.platform === "win32" ? "gradlew.bat" : "./gradlew" function log(msg: string) { console.log(`[jetbrains-build] ${msg}`) } -function distBinPath(os: string, exe: string): string { - return join(distDir, `@kilocode/cli-${os}`, "bin", exe) -} - -function hasArtifacts(os: string, exe: string): boolean { - return existsSync(distBinPath(os, exe)) && existsSync(distBinPath(os, "kilo-sandbox-mutation-worker.js")) -} - -function hasDist(): boolean { - if (production) { - return platforms.every((p) => hasArtifacts(p.os, p.exe)) - } - const tag = localPlatformTag() - const local = platforms.find((p) => p.os === tag) - return local ? hasArtifacts(local.os, local.exe) : false -} - -async function prepareCli() { - const mode = production ? "production" : "local" - log(`Mode: ${mode}`) - - if (!hasDist()) { - log("Building CLI binaries via opencode...") - if (!existsSync(join(opencodeDir, "package.json"))) { - throw new Error(`Expected opencode package at ${opencodeDir}`) - } - const args = production ? [] : ["--single"] - await $`bun run build ${args}`.cwd(opencodeDir) - } else { - log("Found prebuilt CLI binaries in opencode/dist/, skipping CLI build") - } - - if (existsSync(cliDir)) { - rmSync(cliDir, { recursive: true }) - } - - const missing: string[] = [] - let copied = 0 - for (const p of platforms) { - const src = distBinPath(p.os, p.exe) - const worker = distBinPath(p.os, "kilo-sandbox-mutation-worker.js") - if (!existsSync(src) || !existsSync(worker)) { - missing.push(p.os) - continue - } - - const dir = join(cliDir, p.os) - mkdirSync(dir, { recursive: true }) - const dest = join(dir, p.exe) - cpSync(src, dest) - cpSync(worker, join(dir, "kilo-sandbox-mutation-worker.js")) - chmodSync(dest, 0o755) - copied++ - log(`Copied ${relative(root, src)} and Kilo sandbox mutation worker -> ${relative(root, dir)}`) - } - - if (copied === 0) { - throw new Error("No CLI binaries were copied — the build cannot proceed") - } - - if (production && missing.length > 0) { - throw new Error(`Production build requires all platform binaries. Missing: ${missing.join(", ")}`) - } - - if (missing.length > 0) { - log(`Skipped ${missing.length} platforms (not needed for local build): ${missing.join(", ")}`) - } - - log(`Copied ${copied}/${platforms.length} platform binaries`) -} - -const gradlew = process.platform === "win32" ? "gradlew.bat" : "./gradlew" - async function buildPlugin() { log("Building JetBrains plugin via Gradle...") const args = production ? ["-Pproduction=true"] : [] @@ -130,10 +30,7 @@ async function buildPlugin() { } try { - await prepareCli() - if (!cliOnly) { - await buildPlugin() - } + await buildPlugin() } catch (err) { console.error(`[jetbrains-build] ERROR: ${err instanceof Error ? err.message : String(err)}`) process.exit(1) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index fa1bb85079b..91f43499cae 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -39,6 +39,9 @@ interface KiloAppRpcApi : RemoteApi { /** One-shot health check against /global/health. */ suspend fun health(): HealthDto + /** Pinned CLI version bundled in backend resources. */ + suspend fun cliVersion(): String + /** Retry app connection or loading after a failure. */ suspend fun retry() diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index cbc72c6db79..ab1ec2c2613 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.SerialName @Serializable enum class KiloAppStatusDto { DISCONNECTED, + DOWNLOADING, CONNECTING, LOADING, MIGRATION_REQUIRED, @@ -173,6 +174,9 @@ data class KiloAppStateDto( val error: String? = null, val errors: List = emptyList(), val progress: LoadProgressDto? = null, + val downloadPercent: Int? = null, + val downloadVersion: String? = null, + val downloadPlatform: String? = null, val warnings: List = emptyList(), val config: ConfigDto? = null, val profile: ProfileDto? = null, From 891a7a5847935a8319faa892226a5683c2e01e30 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 6 Jul 2026 15:44:16 -0400 Subject: [PATCH 2/7] feat(jetbrains): show Core runtime info --- .changeset/jetbrains-download-cli.md | 2 +- .../kilocode/backend/rpc/KiloAppRpcApiImpl.kt | 3 + .../kilocode/client/actions/CoreInfoAction.kt | 27 +++++++++ .../ai/kilocode/client/app/KiloAppService.kt | 59 ++++++++++++------- .../ai/kilocode/client/session/SessionUi.kt | 7 --- .../session/ui/empty/EmptySessionPanel.kt | 23 +------- .../resources/kilo.jetbrains.frontend.xml | 7 ++- .../resources/messages/KiloBundle.properties | 22 +++---- .../messages/KiloBundle_ar.properties | 12 ++-- .../messages/KiloBundle_bs.properties | 12 ++-- .../messages/KiloBundle_da.properties | 12 ++-- .../messages/KiloBundle_de.properties | 12 ++-- .../messages/KiloBundle_es.properties | 12 ++-- .../messages/KiloBundle_fr.properties | 12 ++-- .../messages/KiloBundle_ja.properties | 12 ++-- .../messages/KiloBundle_ko.properties | 12 ++-- .../messages/KiloBundle_nl.properties | 12 ++-- .../messages/KiloBundle_no.properties | 12 ++-- .../messages/KiloBundle_pl.properties | 12 ++-- .../messages/KiloBundle_pt_BR.properties | 12 ++-- .../messages/KiloBundle_ru.properties | 12 ++-- .../messages/KiloBundle_th.properties | 12 ++-- .../messages/KiloBundle_tr.properties | 12 ++-- .../messages/KiloBundle_uk.properties | 12 ++-- .../messages/KiloBundle_zh_CN.properties | 12 ++-- .../messages/KiloBundle_zh_TW.properties | 12 ++-- .../client/actions/KiloRecoveryActionsTest.kt | 40 +++++++++++-- .../client/session/ui/ConnectionPanelTest.kt | 5 +- .../session/ui/EmptySessionPanelTest.kt | 17 ------ .../kilocode/client/testing/FakeAppRpcApi.kt | 6 ++ .../kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt | 9 ++- 31 files changed, 245 insertions(+), 198 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt diff --git a/.changeset/jetbrains-download-cli.md b/.changeset/jetbrains-download-cli.md index 013e6299358..8885c1a8808 100644 --- a/.changeset/jetbrains-download-cli.md +++ b/.changeset/jetbrains-download-cli.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Download the pinned Kilo CLI release at connect time instead of bundling CLI binaries in the plugin. +Download the pinned Kilo Core release at connect time instead of bundling Core binaries in the plugin. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index 5c2691a6b12..a68352daa51 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -9,6 +9,7 @@ import ai.kilocode.backend.app.ConfigWarning import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.LoadProgress import ai.kilocode.backend.app.ProfileResult +import ai.kilocode.backend.cli.KiloCliPlatform import ai.kilocode.backend.cli.KiloProps import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.ConfigPatchDto @@ -54,6 +55,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun cliVersion(): String = KiloProps.cliVersion() + override suspend fun cliPlatform(): String = KiloCliPlatform.current() + override suspend fun retry() = app.retry() override suspend fun restart() = app.restart() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt new file mode 100644 index 00000000000..1bad925ec0c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt @@ -0,0 +1,27 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.plugin.KiloBundle +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware + +class CoreInfoAction : AnAction(), DumbAware { + override fun actionPerformed(e: AnActionEvent) = Unit + + override fun update(e: AnActionEvent) { + val app = service() + val info = app.core + if (info == null) app.fetchCoreInfoAsync() + e.presentation.text = info?.let { + KiloBundle.message("action.Kilo.CoreInfo.text", it.version, it.platform) + } ?: KiloBundle.message("action.Kilo.CoreInfo.loading") + e.presentation.description = KiloBundle.message("action.Kilo.CoreInfo.description") + e.presentation.isEnabled = false + e.presentation.isVisible = true + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 2ef806cfa98..7edea34c4a7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -27,7 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** - * App-level frontend service for Kilo CLI interaction. + * App-level frontend service for Kilo Core interaction. * * Communicates with the backend via [KiloAppRpcApi]. All operations * are app-scoped — no project context is needed. @@ -47,10 +47,13 @@ class KiloAppService internal constructor( private val started = AtomicBoolean(false) - /** CLI version string from the last successful health check, or null if unknown. */ + /** Core version and platform from the backend, or null if unknown. */ @Volatile - var version: String? = null - private set + private var info: CoreInfo? = null + + val core: CoreInfo? get() = info + + val version: String? get() = info?.version internal val _state = MutableStateFlow(init) val state: StateFlow = _state.asStateFlow() @@ -80,6 +83,9 @@ class KiloAppService internal constructor( private fun onState(state: KiloAppStateDto) { _state.value = state + val version = state.downloadVersion + val platform = state.downloadPlatform + if (version != null && platform != null) info = CoreInfo(version, platform) if (state.status == KiloAppStatusDto.READY) refreshModelFavoritesAsync() } @@ -96,20 +102,20 @@ class KiloAppService internal constructor( call { retry() } } - /** Kill the CLI process and restart it. */ + /** Kill the Core process and restart it. */ suspend fun restart() { LOG.info("restart: resetting state and sending RPC") started.set(false) - version = null + info = null call { restart() } LOG.info("restart: RPC returned — backend restart complete") } - /** Kill the CLI process, re-extract the binary, and restart. */ + /** Kill the Core process, re-download the binary, and restart. */ suspend fun reinstall() { LOG.info("reinstall: resetting state and sending RPC") started.set(false) - version = null + info = null call { reinstall() } LOG.info("reinstall: RPC returned — backend reinstall complete") } @@ -131,32 +137,41 @@ class KiloAppService internal constructor( cs.launch { reinstall() } } - suspend fun cliVersion(): String? = try { - call { cliVersion() } + suspend fun coreInfo(): CoreInfo? = try { + val next = CoreInfo( + version = call { cliVersion() }, + platform = call { cliPlatform() }, + ) + info = next + next } catch (e: Exception) { - LOG.warn("cliVersion failed", e) + LOG.warn("core info failed", e) null } - /** Fetch the pinned CLI version and cache it. */ - fun fetchVersionAsync(done: (String?) -> Unit = {}) { - version?.let { + /** Fetch the pinned Core version and platform and cache it. */ + fun fetchCoreInfoAsync(done: (CoreInfo?) -> Unit = {}) { + info?.let { done(it) return } cs.launch { - LOG.info("fetchVersion: requesting CLI version") - val text = cliVersion() - if (text == null) { + LOG.info("fetchCoreInfo: requesting Core version and platform") + val next = coreInfo() + if (next == null) { done(null) return@launch } - version = text - done(text) - LOG.info("fetchVersion: CLI version is $text") + done(next) + LOG.info("fetchCoreInfo: Core version is ${next.version} (${next.platform})") } } + /** Fetch the pinned Core version and cache it. */ + fun fetchVersionAsync(done: (String?) -> Unit = {}) { + fetchCoreInfoAsync { done(it?.version) } + } + fun refreshModelFavoritesAsync() { cs.launch { try { @@ -310,7 +325,7 @@ class KiloAppService internal constructor( fun watch(fn: (KiloAppStateDto) -> Unit): Job { return cs.launch { state.collect { next -> - if (next.status == KiloAppStatusDto.READY) fetchVersionAsync() + if (next.status == KiloAppStatusDto.READY) fetchCoreInfoAsync() fn(next) } } @@ -325,6 +340,8 @@ class KiloAppService internal constructor( } } +data class CoreInfo(val version: String, val platform: String) + private fun summary(patch: ConfigPatchDto): String { val values = patch.values.keys.sorted().joinToString(",").ifEmpty { "none" } return "values=$values agents=${patch.agents.size}" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 08e8faaede7..2e7945cd4ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -485,14 +485,8 @@ class SessionUi( activity = { manager?.activity() ?: sessions.activity() }, titles = { manager?.titles().orEmpty() }, timers = timers, - cliVersion = app.version, ) empty = panel - app.fetchVersionAsync { version -> - ApplicationManager.getApplication().invokeLater { - if (!disposed && empty === panel) panel.setCliVersion(version) - } - } scroll.show(panel.view) } @@ -503,7 +497,6 @@ class SessionUi( is SessionControllerEvent.AppChanged -> { prompt.setReady(controller.model.isReady()) - empty?.setCliVersion(app.version) } is SessionControllerEvent.WorkspaceChanged -> { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt index c8b7a7cdc5d..f277884c261 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -55,7 +55,6 @@ class EmptySessionPanel( private val titles: () -> Map = { emptyMap() }, private val browse: (String) -> Unit = BrowserUtil::browse, private val timers: UiTimerSource = UiTimers, - cliVersion: String? = null, ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { val view: Align = align(HAlign.CENTER, VAlign.CENTER) @@ -74,13 +73,6 @@ class EmptySessionPanel( setAllowAutoWrapping(true) } - private val versionLabel = JBLabel().apply { - foreground = UIUtil.getContextHelpForeground() - font = com.intellij.util.ui.JBFont.small() - horizontalAlignment = JBLabel.CENTER - isVisible = false - } - private val description = object : BorderLayoutPanel() { override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() @@ -120,15 +112,11 @@ class EmptySessionPanel( ).apply { horizontalAlignment = JBLabel.CENTER } - val logoBlock = Stack.vertical(gap = UiStyle.Gap.xs()) - .next(logo) - .next(versionLabel) val header = BorderLayoutPanel(0, gap).apply { isOpaque = false - add(logoBlock, BorderLayout.NORTH) + add(logo, BorderLayout.NORTH) add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) } - setCliVersion(cliVersion) val south = BorderLayoutPanel().apply { isOpaque = false @@ -170,15 +158,6 @@ class EmptySessionPanel( internal fun feedbackContent(open: (String) -> Unit = {}): JComponent = EmptySessionFeedback.content(open) - @RequiresEdt - fun setCliVersion(version: String?) { - val text = version?.takeIf { it.isNotBlank() } - versionLabel.text = text?.let { KiloBundle.message("session.empty.cliVersion", it) }.orEmpty() - versionLabel.isVisible = text != null - revalidate() - repaint() - } - internal fun feedbackUrls() = EmptySessionFeedback.urls() internal fun showHistoryBorderPainted() = historyButton.isBorderPainted diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index ecb2986f963..1d913645b23 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -105,6 +105,9 @@ + + @@ -114,9 +117,11 @@ - + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 00285f7666e..718b30439fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -1,8 +1,8 @@ common.delete=Delete session.connection.connecting=Loading... -session.connection.downloading=Downloading Kilo CLI… {0}% -session.connection.downloading.version=Downloading Kilo CLI v{0} ({1})… {2}% +session.connection.downloading=Downloading Kilo Core… {0}% +session.connection.downloading.version=Downloading Kilo Core v{0} ({1})… {2}% session.connection.error.app=Connection failed session.connection.error.workspace=Workspace loading failed session.connection.error.unknown=Unknown error @@ -12,7 +12,6 @@ session.connection.warning.config=Configuration warnings notification.group.kilo=Kilo Code session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. -session.empty.cliVersion=Kilo CLI v{0} session.account.balance=Balance: {0} session.account.switcher=Switch account session.empty.loading=Loading... @@ -282,7 +281,7 @@ action.Kilo.ToolWindowToolbar.text=Kilo Toolbar settings.kilo.displayName=Kilo Code settings.kilo.description=Configure Kilo Code AI coding assistant features and account settings. settings.cli.unavailable.title=Kilo Code is not connected -settings.cli.unavailable.message=Settings are available after Kilo Code connects to the CLI. If this does not resolve, restart the IDE or the Kilo CLI and try again. +settings.cli.unavailable.message=Settings are available after Kilo Code connects to Core. If this does not resolve, restart the IDE or Kilo Core and try again. settings.models.displayName=Models settings.providers.displayName=Providers settings.agentBehavior.displayName=Agent Behavior @@ -539,14 +538,17 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions action.Kilo.Restart.text=Restart -action.Kilo.Restart.cli.text=Restart CLI -action.Kilo.Restart.description=Kill and restart the CLI process +action.Kilo.Restart.cli.text=Restart Core +action.Kilo.Restart.description=Kill and restart the Core process action.Kilo.Reinstall.text=Reinstall -action.Kilo.Reinstall.cli.text=Reinstall CLI -action.Kilo.Reinstall.description=Re-extract the CLI binary and restart +action.Kilo.Reinstall.cli.text=Reinstall Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.text=Core v{0} • Architecture: {1} +action.Kilo.CoreInfo.loading=Core details loading... +action.Kilo.CoreInfo.description=Kilo Core version and architecture action.Kilo.Session.Open.text=Open action.Kilo.Session.Open.description=Open the selected session action.Kilo.Session.Rename.text=Rename diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 080f53bf16d..54a06c17255 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=إيقاف جلسة Kilo الحالية action.Kilo.SettingsGroup.text=الإعدادات action.Kilo.SettingsGroup.description=إعدادات Kilo Code action.Kilo.Restart.text=إعادة تشغيل Kilo -action.Kilo.Restart.cli.text=إعادة تشغيل CLI -action.Kilo.Restart.description=إنهاء عملية CLI وإعادة تشغيلها +action.Kilo.Restart.cli.text=إعادة تشغيل Core +action.Kilo.Restart.description=إنهاء عملية Core وإعادة تشغيلها action.Kilo.Reinstall.text=إعادة تثبيت Kilo -action.Kilo.Reinstall.cli.text=إعادة تثبيت CLI -action.Kilo.Reinstall.description=استخراج CLI مجدداً وإعادة التشغيل +action.Kilo.Reinstall.cli.text=إعادة تثبيت Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=فتح action.Kilo.Session.Open.description=فتح الجلسة المحددة action.Kilo.Session.Rename.text=إعادة تسمية @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index a68d6dd079b..8589d3cc662 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Zaustavi trenutnu Kilo sesiju action.Kilo.SettingsGroup.text=Postavke action.Kilo.SettingsGroup.description=Postavke Kilo Code action.Kilo.Restart.text=Ponovo pokrenite Kilo -action.Kilo.Restart.cli.text=Ponovo pokrenite CLI -action.Kilo.Restart.description=Ugasite i ponovo pokrenite CLI proces +action.Kilo.Restart.cli.text=Ponovo pokrenite Core +action.Kilo.Restart.description=Ugasite i ponovo pokrenite Core proces action.Kilo.Reinstall.text=Ponovo instalirajte Kilo -action.Kilo.Reinstall.cli.text=Ponovo instalirajte CLI -action.Kilo.Reinstall.description=Ponovo raspakirajte CLI binarnu datoteku i ponovo pokrenite +action.Kilo.Reinstall.cli.text=Ponovo instalirajte Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Otvori action.Kilo.Session.Open.description=Otvorite odabranu sesiju action.Kilo.Session.Rename.text=Preimenuj @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index d2d1ed7e20c..b14a7d7f371 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Stop den aktuelle Kilo-session action.Kilo.SettingsGroup.text=Indstillinger action.Kilo.SettingsGroup.description=Kilo Code-indstillinger action.Kilo.Restart.text=Genstart Kilo -action.Kilo.Restart.cli.text=Genstart CLI -action.Kilo.Restart.description=Afslut og genstart CLI-processen +action.Kilo.Restart.cli.text=Genstart Core +action.Kilo.Restart.description=Afslut og genstart Core-processen action.Kilo.Reinstall.text=Geninstaller Kilo -action.Kilo.Reinstall.cli.text=Geninstaller CLI -action.Kilo.Reinstall.description=Udpak CLI-binærfilen igen og genstart +action.Kilo.Reinstall.cli.text=Geninstaller Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Åbn action.Kilo.Session.Open.description=Åbn den valgte session action.Kilo.Session.Rename.text=Omdøb @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 7a7a691fefb..508372e4255 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Aktuelle Kilo-Sitzung stoppen action.Kilo.SettingsGroup.text=Einstellungen action.Kilo.SettingsGroup.description=Kilo Code Einstellungen action.Kilo.Restart.text=Kilo neu starten -action.Kilo.Restart.cli.text=CLI neu starten -action.Kilo.Restart.description=CLI-Prozess beenden und neu starten +action.Kilo.Restart.cli.text=Core neu starten +action.Kilo.Restart.description=Core-Prozess beenden und neu starten action.Kilo.Reinstall.text=Kilo neu installieren -action.Kilo.Reinstall.cli.text=CLI neu installieren -action.Kilo.Reinstall.description=CLI-Binärdatei neu extrahieren und neu starten +action.Kilo.Reinstall.cli.text=Core neu installieren +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Öffnen action.Kilo.Session.Open.description=Ausgewählte Sitzung öffnen action.Kilo.Session.Rename.text=Umbenennen @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index baddfdc2248..eaae1f4cbf9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Detener la sesión actual de Kilo action.Kilo.SettingsGroup.text=Configuración action.Kilo.SettingsGroup.description=Configuración de Kilo Code action.Kilo.Restart.text=Reiniciar Kilo -action.Kilo.Restart.cli.text=Reiniciar CLI -action.Kilo.Restart.description=Terminar y reiniciar el proceso CLI +action.Kilo.Restart.cli.text=Reiniciar Core +action.Kilo.Restart.description=Terminar y reiniciar el proceso Core action.Kilo.Reinstall.text=Reinstalar Kilo -action.Kilo.Reinstall.cli.text=Reinstalar CLI -action.Kilo.Reinstall.description=Reextraer el binario CLI y reiniciar +action.Kilo.Reinstall.cli.text=Reinstalar Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Abrir action.Kilo.Session.Open.description=Abrir la sesión seleccionada action.Kilo.Session.Rename.text=Renombrar @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 1d938db28e5..ba0795ca4e7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Arrêter la session Kilo actuelle action.Kilo.SettingsGroup.text=Paramètres action.Kilo.SettingsGroup.description=Paramètres de Kilo Code action.Kilo.Restart.text=Redémarrer Kilo -action.Kilo.Restart.cli.text=Redémarrer CLI -action.Kilo.Restart.description=Tuer et redémarrer le processus CLI +action.Kilo.Restart.cli.text=Redémarrer Core +action.Kilo.Restart.description=Tuer et redémarrer le processus Core action.Kilo.Reinstall.text=Réinstaller Kilo -action.Kilo.Reinstall.cli.text=Réinstaller CLI -action.Kilo.Reinstall.description=Réextraire le binaire CLI et redémarrer +action.Kilo.Reinstall.cli.text=Réinstaller Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Ouvrir action.Kilo.Session.Open.description=Ouvrir la session sélectionnée action.Kilo.Session.Rename.text=Renommer @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 3912f106aaa..360e3124981 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=現在のKiloセッションを停止 action.Kilo.SettingsGroup.text=設定 action.Kilo.SettingsGroup.description=Kilo Codeの設定 action.Kilo.Restart.text=Kiloを再起動 -action.Kilo.Restart.cli.text=CLIを再起動 -action.Kilo.Restart.description=CLIプロセスを終了して再起動 +action.Kilo.Restart.cli.text=Coreを再起動 +action.Kilo.Restart.description=Coreプロセスを終了して再起動 action.Kilo.Reinstall.text=Kiloを再インストール -action.Kilo.Reinstall.cli.text=CLIを再インストール -action.Kilo.Reinstall.description=CLIバイナリを再展開して再起動 +action.Kilo.Reinstall.cli.text=Coreを再インストール +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=開く action.Kilo.Session.Open.description=選択したセッションを開く action.Kilo.Session.Rename.text=変名 @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 82d758dce12..970ddaf16d9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=현재 Kilo 세션 정지 action.Kilo.SettingsGroup.text=설정 action.Kilo.SettingsGroup.description=Kilo Code 설정 action.Kilo.Restart.text=Kilo 재시작 -action.Kilo.Restart.cli.text=CLI 재시작 -action.Kilo.Restart.description=CLI 프로세스 종료 후 재시작 +action.Kilo.Restart.cli.text=Core 재시작 +action.Kilo.Restart.description=Core 프로세스 종료 후 재시작 action.Kilo.Reinstall.text=Kilo 재설치 -action.Kilo.Reinstall.cli.text=CLI 재설치 -action.Kilo.Reinstall.description=CLI 바이너리 재추출 후 재시작 +action.Kilo.Reinstall.cli.text=Core 재설치 +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=열기 action.Kilo.Session.Open.description=선택한 세션 열기 action.Kilo.Session.Rename.text=이름 바꾸기 @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index 72e2813f190..7e19e9aee2c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Huidige Kilo-sessie stoppen action.Kilo.SettingsGroup.text=Instellingen action.Kilo.SettingsGroup.description=Kilo Code-instellingen action.Kilo.Restart.text=Kilo herstarten -action.Kilo.Restart.cli.text=CLI herstarten -action.Kilo.Restart.description=CLI-proces beëindigen en herstarten +action.Kilo.Restart.cli.text=Core herstarten +action.Kilo.Restart.description=Core-proces beëindigen en herstarten action.Kilo.Reinstall.text=Kilo herinstalleren -action.Kilo.Reinstall.cli.text=CLI herinstalleren -action.Kilo.Reinstall.description=CLI-binair opnieuw uitpakken en herstarten +action.Kilo.Reinstall.cli.text=Core herinstalleren +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Openen action.Kilo.Session.Open.description=Geselecteerde sessie openen action.Kilo.Session.Rename.text=Hernoemen @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 77052f93672..d55e8a0166a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=Stopp gjeldende Kilo-økt action.Kilo.SettingsGroup.text=Innstillinger action.Kilo.SettingsGroup.description=Kilo Code-innstillinger action.Kilo.Restart.text=Start Kilo på nytt -action.Kilo.Restart.cli.text=Start CLI på nytt -action.Kilo.Restart.description=Avslutt og start CLI-prosessen på nytt +action.Kilo.Restart.cli.text=Start Core på nytt +action.Kilo.Restart.description=Avslutt og start Core-prosessen på nytt action.Kilo.Reinstall.text=Installer Kilo på nytt -action.Kilo.Reinstall.cli.text=Installer CLI på nytt -action.Kilo.Reinstall.description=Pakk ut CLI-binærfilen på nytt og start på nytt +action.Kilo.Reinstall.cli.text=Installer Core på nytt +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Åpne action.Kilo.Session.Open.description=Åpne valgt økt action.Kilo.Session.Rename.text=Gi nytt navn @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 171ac2d8241..dfa0f2442d8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=Zatrzymaj bieżącą sesję Kilo action.Kilo.SettingsGroup.text=Ustawienia action.Kilo.SettingsGroup.description=Ustawienia Kilo Code action.Kilo.Restart.text=Uruchom ponownie Kilo -action.Kilo.Restart.cli.text=Uruchom ponownie CLI -action.Kilo.Restart.description=Zakończ i uruchom ponownie proces CLI +action.Kilo.Restart.cli.text=Uruchom ponownie Core +action.Kilo.Restart.description=Zakończ i uruchom ponownie proces Core action.Kilo.Reinstall.text=Zainstaluj ponownie Kilo -action.Kilo.Reinstall.cli.text=Zainstaluj ponownie CLI -action.Kilo.Reinstall.description=Ponownie wyodrębnij plik binarny CLI i uruchom ponownie +action.Kilo.Reinstall.cli.text=Zainstaluj ponownie Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Otwórz action.Kilo.Session.Open.description=Otwórz wybraną sesję action.Kilo.Session.Rename.text=Zmień nazwę @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index cec8cecbdd2..aac69eaae96 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=Parar a sessão atual do Kilo action.Kilo.SettingsGroup.text=Configurações action.Kilo.SettingsGroup.description=Configurações do Kilo Code action.Kilo.Restart.text=Reiniciar Kilo -action.Kilo.Restart.cli.text=Reiniciar CLI -action.Kilo.Restart.description=Encerrar e reiniciar o processo CLI +action.Kilo.Restart.cli.text=Reiniciar Core +action.Kilo.Restart.description=Encerrar e reiniciar o processo Core action.Kilo.Reinstall.text=Reinstalar Kilo -action.Kilo.Reinstall.cli.text=Reinstalar CLI -action.Kilo.Reinstall.description=Reextrair o binário CLI e reiniciar +action.Kilo.Reinstall.cli.text=Reinstalar Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Abrir action.Kilo.Session.Open.description=Abrir a sessão selecionada action.Kilo.Session.Rename.text=Renomear @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index f9528d1e32a..edf8aea4b50 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=Остановить текущую сесс action.Kilo.SettingsGroup.text=Настройки action.Kilo.SettingsGroup.description=Настройки Kilo Code action.Kilo.Restart.text=Перезапустить Kilo -action.Kilo.Restart.cli.text=Перезапустить CLI -action.Kilo.Restart.description=Завершить и перезапустить CLI-процесс +action.Kilo.Restart.cli.text=Перезапустить Core +action.Kilo.Restart.description=Завершить и перезапустить Core-процесс action.Kilo.Reinstall.text=Переустановить Kilo -action.Kilo.Reinstall.cli.text=Переустановить CLI -action.Kilo.Reinstall.description=Переизвлечь CLI-бинарник и перезапустить +action.Kilo.Reinstall.cli.text=Переустановить Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Открыть action.Kilo.Session.Open.description=Открыть выбранную сессию action.Kilo.Session.Rename.text=Переименовать @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index c07d2fe693a..192fbafa392 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=หยุดเซสชัน Kilo ปั action.Kilo.SettingsGroup.text=การตั้งค่า action.Kilo.SettingsGroup.description=การตั้งค่า Kilo Code action.Kilo.Restart.text=รีสตาร์ต Kilo -action.Kilo.Restart.cli.text=รีสตาร์ต CLI -action.Kilo.Restart.description=สิ้นสุดและรีสตาร์ตกระบวนการ CLI +action.Kilo.Restart.cli.text=รีสตาร์ต Core +action.Kilo.Restart.description=สิ้นสุดและรีสตาร์ตกระบวนการ Core action.Kilo.Reinstall.text=ติดตั้ง Kilo ใหม่ -action.Kilo.Reinstall.cli.text=ติดตั้ง CLI ใหม่ -action.Kilo.Reinstall.description=แตกไฟล์ไบนารี CLI อีกครั้งและรีสตาร์ต +action.Kilo.Reinstall.cli.text=ติดตั้ง Core ใหม่ +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=เปิด action.Kilo.Session.Open.description=เปิดเซสชันที่เลือก action.Kilo.Session.Rename.text=เปลี่ยนชื่อ @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index ed9646acac2..3976303b098 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=Geçerli Kilo oturumunu durdur action.Kilo.SettingsGroup.text=Ayarlar action.Kilo.SettingsGroup.description=Kilo Code ayarları action.Kilo.Restart.text=Kilo’yu yeniden başlat -action.Kilo.Restart.cli.text=CLI’yi yeniden başlat -action.Kilo.Restart.description=CLI sürecini sonlandır ve yeniden başlat +action.Kilo.Restart.cli.text=Core’yi yeniden başlat +action.Kilo.Restart.description=Core sürecini sonlandır ve yeniden başlat action.Kilo.Reinstall.text=Kilo’yu yeniden yükle -action.Kilo.Reinstall.cli.text=CLI’yi yeniden yükle -action.Kilo.Reinstall.description=CLI ikili dosyasını yeniden çıkar ve yeniden başlat +action.Kilo.Reinstall.cli.text=Core’yi yeniden yükle +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Aç action.Kilo.Session.Open.description=Seçilen oturumu aç action.Kilo.Session.Rename.text=Yeniden adlandır @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 7b6911cf734..6594c293afb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Зупинити поточну сесію K action.Kilo.SettingsGroup.text=Налаштування action.Kilo.SettingsGroup.description=Налаштування Kilo Code action.Kilo.Restart.text=Перезапустити Kilo -action.Kilo.Restart.cli.text=Перезапустити CLI -action.Kilo.Restart.description=Завершити і перезапустити CLI-процес +action.Kilo.Restart.cli.text=Перезапустити Core +action.Kilo.Restart.description=Завершити і перезапустити Core-процес action.Kilo.Reinstall.text=Перевстановити Kilo -action.Kilo.Reinstall.cli.text=Перевстановити CLI -action.Kilo.Reinstall.description=Перерозпакувати CLI-бінарний файл і перезапустити +action.Kilo.Reinstall.cli.text=Перевстановити Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Відкрити action.Kilo.Session.Open.description=Відкрити вибрану сесію action.Kilo.Session.Rename.text=Перейменувати @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index eedfaf9fa9c..959b9c8c9d3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=停止当前 Kilo 会话 action.Kilo.SettingsGroup.text=设置 action.Kilo.SettingsGroup.description=Kilo Code 设置 action.Kilo.Restart.text=重启 Kilo -action.Kilo.Restart.cli.text=重启 CLI -action.Kilo.Restart.description=终止并重启 CLI 进程 +action.Kilo.Restart.cli.text=重启 Core +action.Kilo.Restart.description=终止并重启 Core 进程 action.Kilo.Reinstall.text=重新安装 Kilo -action.Kilo.Reinstall.cli.text=重新安装 CLI -action.Kilo.Reinstall.description=重新解压 CLI 二进制文件并重启 +action.Kilo.Reinstall.cli.text=重新安装 Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=打开 action.Kilo.Session.Open.description=打开所选会话 action.Kilo.Session.Rename.text=重命名 @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 6021a8790a1..b9e0268c877 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=停止目前的 Kilo 工作階段 action.Kilo.SettingsGroup.text=設定 action.Kilo.SettingsGroup.description=Kilo Code 設定 action.Kilo.Restart.text=重啟 Kilo -action.Kilo.Restart.cli.text=重啟 CLI -action.Kilo.Restart.description=終止並重啟 CLI 程序 +action.Kilo.Restart.cli.text=重啟 Core +action.Kilo.Restart.description=終止並重啟 Core 程序 action.Kilo.Reinstall.text=重新安裝 Kilo -action.Kilo.Reinstall.cli.text=重新安裝 CLI -action.Kilo.Reinstall.description=重新解壓縮 CLI 二進位檔案並重啟 +action.Kilo.Reinstall.cli.text=重新安裝 Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=開啟 action.Kilo.Session.Open.description=開啟所選工作階段 action.Kilo.Session.Rename.text=重命名 @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index 14b0d61e4e6..8629fa1c762 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -1,8 +1,10 @@ package ai.kilocode.client.actions +import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.rpc.dto.ConfigTargetDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -30,11 +32,18 @@ import kotlinx.coroutines.withTimeout class KiloRecoveryActionsTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope private lateinit var rpc: FakeWorkspaceRpcApi + private lateinit var appRpc: FakeAppRpcApi override fun setUp() { super.setUp() scope = CoroutineScope(SupervisorJob()) rpc = FakeWorkspaceRpcApi() + appRpc = FakeAppRpcApi() + ApplicationManager.getApplication().replaceService( + KiloAppService::class.java, + KiloAppService(scope, appRpc), + testRootDisposable, + ) ApplicationManager.getApplication().replaceService( KiloWorkspaceService::class.java, KiloWorkspaceService(scope, rpc), @@ -68,38 +77,55 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertTrue("Reinstall should force-enable recovery action", event.presentation.isEnabled) } - fun `test restart action adds cli suffix in connection retry popup`() { + fun `test restart action adds core suffix in connection retry popup`() { val action = RestartKiloAction() val event = event(action, place = KiloActionPlaces.connectionRetryPopup()) update(action, event) - assertEquals("Restart CLI", event.presentation.text) + assertEquals("Restart Core", event.presentation.text) } - fun `test reinstall action adds cli suffix in connection retry popup`() { + fun `test reinstall action adds core suffix in connection retry popup`() { val action = ReinstallKiloAction() val event = event(action, place = KiloActionPlaces.connectionRetryPopup()) update(action, event) - assertEquals("Reinstall CLI", event.presentation.text) + assertEquals("Reinstall Core", event.presentation.text) } - fun `test cli group has visible menu text`() { + fun `test core group has visible menu text and info action`() { val xml = requireNotNull(javaClass.classLoader.getResourceAsStream("kilo.jetbrains.frontend.xml")) .bufferedReader() .use { it.readText() } - assertTrue(xml.contains("")) + assertTrue(xml.contains("")) assertTrue(xml.contains("")) assertTrue(xml.contains("")) + assertTrue(xml.contains("")) assertTrue(xml.contains("")) assertTrue(xml.contains("")) assertFalse(xml.contains("")) } + fun `test core info action shows version and architecture`() { + appRpc.cliVersion = "1.2.3" + appRpc.cliPlatform = "darwin-arm64" + ApplicationManager.getApplication().executeOnPooledThread { + runBlocking { app().coreInfo() } + }.get() + val action = CoreInfoAction() + val event = event(action) + + update(action, event) + + assertFalse(event.presentation.isEnabled) + assertTrue(event.presentation.isVisible) + assertEquals("Core v1.2.3 • Architecture: darwin-arm64", event.presentation.text) + } + fun `test local config action says open when target exists`() { rpc.localConfigPath = "/test/.kilo/kilo.jsonc" rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" @@ -311,6 +337,8 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { private fun service(): KiloWorkspaceService = ApplicationManager.getApplication().getService(KiloWorkspaceService::class.java) + private fun app(): KiloAppService = ApplicationManager.getApplication().getService(KiloAppService::class.java) + private fun cacheGlobal(target: ConfigTargetDto) { val field = KiloWorkspaceService::class.java.getDeclaredField("globalConfig") field.isAccessible = true diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index f969235bb97..e7253e5867a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -94,7 +94,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertEquals("Try again", panel.retryText()) } - fun `test retry popup group uses cli recovery actions`() { + fun `test retry popup group uses core recovery actions`() { edt { panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("CLI startup failed", null)) } @@ -104,9 +104,10 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertTrue(panel.retryVisible()) assertEquals("Kilo.CliGroup", ConnectionPanel.CLI_GROUP_ID) - assertTrue(xml.contains("")) + assertTrue(xml.contains("")) assertTrue(xml.contains("")) assertTrue(xml.contains("")) + assertTrue(xml.contains("")) } fun `test ready warnings show collapsed banner with retry`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index 6f362a0449c..d0c6f0b0eab 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -138,23 +138,6 @@ class EmptySessionPanelTest : BasePlatformTestCase() { ) } - fun `test cli version line updates`() { - val panel = panel() - val text = KiloBundle.message("session.empty.cliVersion", "1.2.3") - - panel.setCliVersion("1.2.3") - val label = UIUtil.uiTraverser(panel) - .filter(JBLabel::class.java) - .first { it.text == text } - - assertTrue(label.isVisible) - - panel.setCliVersion(null) - - assertEquals("", label.text) - assertFalse(label.isVisible) - } - fun `test selecting recent session does not open it`() { val panel = panel(listOf(session("ses_1"), session("ses_2"))) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 546ba6e55ff..bc67e9f2d89 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -32,6 +32,7 @@ class FakeAppRpcApi : KiloAppRpcApi { val state = MutableStateFlow(KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)) var health = HealthDto(healthy = true, version = "1.0.0") var cliVersion = "1.0.0" + var cliPlatform = "darwin-arm64" var models = ModelStateDto() val selections = mutableListOf() val cleared = mutableListOf() @@ -73,6 +74,11 @@ class FakeAppRpcApi : KiloAppRpcApi { return cliVersion } + override suspend fun cliPlatform(): String { + assertNotEdt("cliPlatform") + return cliPlatform + } + override suspend fun retry() { assertNotEdt("retry") retries += 1 diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index 91f43499cae..e05b4c3bdf1 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -39,16 +39,19 @@ interface KiloAppRpcApi : RemoteApi { /** One-shot health check against /global/health. */ suspend fun health(): HealthDto - /** Pinned CLI version bundled in backend resources. */ + /** Pinned Core version bundled in backend resources. */ suspend fun cliVersion(): String + /** Core platform downloaded by the backend process. */ + suspend fun cliPlatform(): String + /** Retry app connection or loading after a failure. */ suspend fun retry() - /** Kill the CLI process and restart it. */ + /** Kill the Core process and restart it. */ suspend fun restart() - /** Kill the CLI process, re-extract the binary, and restart. */ + /** Kill the Core process, re-download the binary, and restart. */ suspend fun reinstall() /** Load persisted CLI model state such as favorites. */ From a435baedaca3055c5b34983f979c64fe1a136614 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 6 Jul 2026 16:10:38 -0400 Subject: [PATCH 3/7] fix(jetbrains): address runtime CLI review feedback --- .changeset/jetbrains-download-cli.md | 2 +- .../kilo-jetbrains/backend/build.gradle.kts | 3 +- .../backend/app/KiloBackendAppService.kt | 2 +- .../backend/app/KiloBackendSessionManager.kt | 14 ++- .../kilocode/backend/cli/KiloCliDownloader.kt | 97 ++++++++++++++++--- .../kilocode/backend/rpc/KiloAppRpcApiImpl.kt | 11 ++- .../backend/app/KiloBackendAppServiceTest.kt | 30 +++++- .../backend/cli/KiloCliDownloaderTest.kt | 60 +++++++++++- .../workspace/KiloBackendWorkspaceTest.kt | 14 +++ .../main/kotlin/GenerateOpenApiSpecTask.kt | 77 ++++++++++++++- .../ai/kilocode/client/app/KiloAppService.kt | 2 +- 11 files changed, 277 insertions(+), 35 deletions(-) diff --git a/.changeset/jetbrains-download-cli.md b/.changeset/jetbrains-download-cli.md index 8885c1a8808..82f2b3bb0bd 100644 --- a/.changeset/jetbrains-download-cli.md +++ b/.changeset/jetbrains-download-cli.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Download the pinned Kilo Core release at connect time instead of bundling Core binaries in the plugin. +Reduce JetBrains plugin size by downloading the Kilo Core release on first connect. diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 65da6183abe..f6660399878 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -18,8 +18,7 @@ val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.jso val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json") val generatedProps = layout.buildDirectory.dir("generated/kilo-props") -val pinnedCliVersion = providers.provider { - val text = rootProject.layout.projectDirectory.file("package.json").asFile.readText() +val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text -> Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1) ?: error("Could not read version from package.json") } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 5b3cefd0b7d..c815f95444e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -97,7 +97,7 @@ class KiloBackendAppService private constructor( private const val MAX_RETRIES = 3 private const val RETRY_DELAY_MS = 1000L private const val APP_LOAD_TIMEOUT_MS = 30_000L - private const val READY_TIMEOUT_MS = 5_000L + private const val READY_TIMEOUT_MS = 120_000L /** Test factory — no IntelliJ deps needed. */ internal fun create( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index 51dbab97280..9df08f47234 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -271,8 +271,8 @@ class KiloBackendSessionManager( title = s.title, version = s.version, time = SessionTimeDto( - created = s.time.created.toDouble(), - updated = s.time.updated.toDouble(), + created = time(s.id, "created", s.time.created), + updated = time(s.id, "updated", s.time.updated), archived = s.time.archived, ), summary = s.summary?.let { @@ -292,8 +292,8 @@ class KiloBackendSessionManager( title = s.title, version = s.version, time = SessionTimeDto( - created = s.time.created?.toDouble() ?: 0.0, - updated = s.time.updated?.toDouble() ?: 0.0, + created = time(s.id, "created", s.time.created), + updated = time(s.id, "updated", s.time.updated), archived = s.time.archived, ), summary = s.summary?.let { @@ -315,6 +315,12 @@ class KiloBackendSessionManager( private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8) + private fun time(id: String, field: String, value: Number?): Double { + if (value != null) return value.toDouble() + log.warn("Session $id missing $field timestamp; defaulting to 0.0") + return 0.0 + } + private fun escape(value: String) = buildString { for (c in value) { when (c) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index 22cdf292773..b0e75d42488 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -5,53 +5,104 @@ import com.intellij.openapi.application.PathManager import com.intellij.openapi.util.SystemInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import java.io.File +import java.security.MessageDigest +import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream import kotlin.math.roundToInt class KiloCliDownloader( - private val http: OkHttpClient = OkHttpClient(), + private val http: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .build(), private val log: KiloLog = KiloLog.create(KiloCliDownloader::class.java), private val root: File = File(PathManager.getSystemPath(), "kilo/cli"), private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download", + private val api: String = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags", ) { + companion object { + private val DIGEST = Regex("^sha256:[a-f0-9]{64}$") + private val JSON = Json { ignoreUnknownKeys = true } + } + suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File = withContext(Dispatchers.IO) { val platform = KiloCliPlatform.current() val dir = File(File(root, version), platform) val exe = File(dir, "bin/${KiloCliPlatform.exe()}") val done = File(dir, ".complete") + val cached = done.takeIf { it.isFile }?.readText()?.trim() + val ext = KiloCliPlatform.archive(platform) + val archive = File(dir, "kilo-$platform.$ext") - if (force && dir.exists()) { - log.info("Deleting cached CLI $version under ${dir.absolutePath}") - dir.deleteRecursively() - } - - if (exe.isFile && done.isFile) { + if (!force && exe.isFile && cached != null && cached.matches(DIGEST) && matches(archive, cached)) { log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") if (!SystemInfo.isWindows) exe.setExecutable(true) return@withContext exe } + val digest = asset(version, platform, ext) + + if (dir.exists()) { + log.info("Deleting cached CLI $version under ${dir.absolutePath}") + if (!dir.deleteRecursively()) { + throw IllegalStateException("Failed to delete cached Kilo CLI $version under ${dir.absolutePath}") + } + } + + if (!dir.isDirectory && !dir.mkdirs()) { + throw IllegalStateException("Failed to create Kilo CLI cache directory ${dir.absolutePath}") + } + log.info("Kilo CLI $version for $platform is not cached; downloading new release into ${dir.absolutePath}") - dir.mkdirs() - val ext = KiloCliPlatform.archive(platform) - val archive = File(dir, "kilo-$platform.$ext") onProgress(CliDownload(0, version, platform)) download(version, platform, ext, archive, onProgress) + verify(archive, digest) log.info("Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)") extract(archive, dir) if (!exe.isFile) throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}") if (!SystemInfo.isWindows) exe.setExecutable(true) - done.writeText("ok\n") + done.writeText("$digest\n") onProgress(CliDownload(100, version, platform)) exe } + private fun asset(version: String, platform: String, ext: String): String { + val name = "kilo-$platform.$ext" + val url = "${api.trimEnd('/')}/v$version" + log.info("Fetching Kilo CLI release metadata for $version from $url") + val request = Request.Builder() + .url(url) + .header("Accept", "application/vnd.github+json") + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: HTTP ${response.code}") + } + val body = response.body?.string() + ?: throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: empty response body") + val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray + ?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name } + ?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull + ?: throw IllegalStateException("Kilo CLI release $version did not include $name") + if (!digest.matches(DIGEST)) { + throw IllegalStateException("Kilo CLI release $version asset $name has invalid digest") + } + return digest + } + } + private fun download(version: String, platform: String, ext: String, file: File, onProgress: (CliDownload) -> Unit) { val url = url(version, platform, ext) log.info("Downloading Kilo CLI $version for $platform from $url") @@ -85,6 +136,30 @@ class KiloCliDownloader( } } + private fun verify(file: File, digest: String) { + val actual = sum(file) + if (actual == digest) return + if (file.exists() && !file.delete()) log.warn("Failed to delete invalid Kilo CLI archive ${file.absolutePath}") + throw IllegalStateException("Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual") + } + + private fun matches(file: File, digest: String) = file.isFile && sum(file) == digest + + private fun sum(file: File) = "sha256:${sha256(file)}" + + private fun sha256(file: File): String { + val md = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val n = input.read(buffer) + if (n < 0) break + md.update(buffer, 0, n) + } + } + return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } + } + private fun extract(file: File, dir: File) { log.info("Extracting Kilo CLI archive ${file.absolutePath}") if (file.name.endsWith(".zip")) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index a68352daa51..4516ef39cc4 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -155,12 +155,15 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto( organizations = p.profile.organizations.orEmpty().map { org -> ProfileOrganizationDto(id = org.id, name = org.name, role = org.role) }, - balance = p.balance?.let { ProfileBalanceDto(balance = it.balance ?: 0.0) }, + balance = p.balance?.balance?.let { ProfileBalanceDto(balance = it) }, kiloPass = p.kiloPass?.let { + val base = it.currentPeriodBaseCreditsUsd ?: return@let null + val usage = it.currentPeriodUsageUsd ?: return@let null + val bonus = it.currentPeriodBonusCreditsUsd ?: return@let null ProfileKiloPassDto( - currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd ?: 0.0, - currentPeriodUsageUsd = it.currentPeriodUsageUsd ?: 0.0, - currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd ?: 0.0, + currentPeriodBaseCreditsUsd = base, + currentPeriodUsageUsd = usage, + currentPeriodBonusCreditsUsd = bonus, nextBillingAt = it.nextBillingAt, ) }, diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index ee9e145a7bd..c3e97c8f769 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -109,7 +109,7 @@ class KiloBackendAppServiceTest { @Test fun `download progress maps to app state before ready`() = runBlocking { val resolved = CompletableDeferred() - val ready = CompletableDeferred() + val signal = CompletableDeferred() val server = object : CliServer { override var forceExtract = false override fun process(): Process? = null @@ -117,7 +117,7 @@ class KiloBackendAppServiceTest { onProgress(CliDownload(37, "1.2.3", "darwin-arm64")) resolved.await() onResolved() - ready.await() + signal.await() return CliServer.State.Ready(mock.start(), mock.password) } override fun exited(proc: Process) {} @@ -141,7 +141,7 @@ class KiloBackendAppServiceTest { svc.appState.first { it == KiloAppState.Connecting } } - ready.complete(Unit) + signal.complete(Unit) ready(svc) job.join() } @@ -852,6 +852,30 @@ class KiloBackendAppServiceTest { assertEquals("org_1", dto.profile?.currentOrgId) } + @Test + fun `ready dto hides unknown profile amounts`() = runBlocking { + mock.profile = """{ + "profile":{"email":"alice@test.com","name":"Alice"}, + "balance":{"balance":null}, + "kiloPass":{ + "currentPeriodBaseCreditsUsd":null, + "currentPeriodUsageUsd":null, + "currentPeriodBonusCreditsUsd":null, + "nextBillingAt":null + }, + "currentOrgId":null + }""".trimIndent() + val svc = create() + svc.connect() + + ready(svc) + + val dto = appStateDto(svc.appState.value) + assertEquals("alice@test.com", dto.profile?.email) + assertNull(dto.profile?.balance) + assertNull(dto.profile?.kiloPass) + } + @Test fun `refresh profile updates ready dto profile`() = runBlocking { mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index a62a4bb387a..eea3cda1c55 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -4,17 +4,21 @@ import ai.kilocode.backend.testing.TestLog import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okio.Buffer import org.apache.commons.compress.archivers.tar.TarArchiveEntry import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream import org.junit.jupiter.api.io.TempDir import java.io.ByteArrayOutputStream import java.io.File +import java.security.MessageDigest import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue class KiloCliDownloaderTest { @@ -25,19 +29,22 @@ class KiloCliDownloaderTest { fun `downloads extracts and caches pinned cli`() = runBlocking { MockWebServer().use { server -> val bytes = archive() - server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(bytes))) + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) val seen = mutableListOf() val log = TestLog() val cli = KiloCliDownloader( log = log, root = dir, baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), ).resolve("1.2.3", onProgress = { seen.add(it) }) assertTrue(cli.isFile) assertEquals(File(File(dir, "1.2.3"), KiloCliPlatform.current()).absolutePath, cli.parentFile.parentFile.absolutePath) assertEquals("#!/bin/sh\n", cli.readText()) assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile) + assertEquals("/api/v1.2.3", server.takeRequest().path) assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path) assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first()) assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() }) @@ -48,20 +55,55 @@ class KiloCliDownloaderTest { log = log, root = dir, baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), ).resolve("1.2.3", onProgress = { cachedProgress.add(it) }) assertEquals(cli.absolutePath, cached.absolutePath) - assertEquals(1, server.requestCount) + assertEquals(2, server.requestCount) assertTrue(cachedProgress.isEmpty()) assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}") - server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(bytes))) + File(cli.parentFile.parentFile, ".complete").writeText("ok\n") + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + val stale = KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + assertEquals(cli.absolutePath, stale.absolutePath) + assertEquals(4, server.requestCount) + + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) val forced = KiloCliDownloader( log = log, root = dir, baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), ).resolve("1.2.3", force = true) assertEquals(cli.absolutePath, forced.absolutePath) - assertEquals(2, server.requestCount) + assertEquals(6, server.requestCount) + } + } + + @Test + fun `rejects cli archive with mismatched digest`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive() + server.enqueue(metadata("sha256:${sha256("different".toByteArray())}")) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + + val ex = assertFailsWith { + KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + + assertContains(ex.message.orEmpty(), "digest mismatch") + assertFalse(File(File(File(dir, "1.2.3"), KiloCliPlatform.current()), ".complete").exists()) } } @@ -74,6 +116,16 @@ class KiloCliDownloaderTest { return tar(files) } + private fun metadata(bytes: ByteArray) = metadata("sha256:${sha256(bytes)}") + + private fun metadata(digest: String) = MockResponse().setResponseCode(200).setBody( + """{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}","digest":"$digest"}]}""" + ) + + private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + private fun zip(files: Map): ByteArray { val out = ByteArrayOutputStream() ZipOutputStream(out).use { zip -> diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index 8107264e804..817c8ca4af3 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -404,6 +404,20 @@ class KiloBackendWorkspaceTest { assertEquals("ses_1", result.sessions[0].id) } + @Test + fun `workspace maps missing session timestamps to zero`() = runBlocking { + mock.sessions = """[ + {"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":null,"updated":null}} + ]""" + val app = setup() + val ws = ready(app) + loaded(ws) + + val session = ws.sessions().sessions.single() + assertEquals(0.0, session.time.created) + assertEquals(0.0, session.time.updated) + } + @Test fun `workspace creates session in its directory`() = runBlocking { mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}""" diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt index aebd99feee4..0d1d65ecb87 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -1,3 +1,8 @@ +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import org.gradle.api.DefaultTask @@ -14,6 +19,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.net.HttpURLConnection import java.net.URI +import java.security.MessageDigest import java.util.zip.ZipInputStream import javax.inject.Inject @@ -21,6 +27,12 @@ import javax.inject.Inject * Generates the CLI OpenAPI spec from the pinned release binary. */ abstract class GenerateOpenApiSpecTask : DefaultTask() { + companion object { + private val DIGEST = Regex("^sha256:[a-f0-9]{64}$") + private val JSON = Json { ignoreUnknownKeys = true } + private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags" + } + @get:Input abstract val cliVersion: Property @@ -68,20 +80,53 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { val dir = cacheDir.dir(version).map { it.dir(platform) }.get().asFile val exe = File(dir, "bin/${exe()}") val done = File(dir, ".complete") - if (exe.isFile && done.isFile) { + val cached = done.takeIf { it.isFile }?.readText()?.trim() + val archive = File(dir, "kilo-$platform.$ext") + if (exe.isFile && cached != null && cached.matches(DIGEST) && matches(archive, cached)) { if (!windows()) exe.setExecutable(true) return exe } - dir.mkdirs() - val archive = File(dir, "kilo-$platform.$ext") + + val name = "kilo-$platform.$ext" + val digest = asset(version, name) + if (dir.exists() && !dir.deleteRecursively()) { + throw GradleException("Failed to delete cached pinned Kilo CLI under ${dir.absolutePath}") + } + if (!dir.isDirectory && !dir.mkdirs()) { + throw GradleException("Failed to create pinned Kilo CLI cache directory ${dir.absolutePath}") + } download("https://github.com/Kilo-Org/kilocode/releases/download/v$version/kilo-$platform.$ext", archive) + verify(archive, digest) extract(archive, dir) if (!exe.isFile) throw GradleException("Downloaded CLI archive did not contain bin/${exe()}") if (!windows()) exe.setExecutable(true) - done.writeText("ok\n") + done.writeText("$digest\n") return exe } + private fun asset(version: String, name: String): String { + val url = "$API/v$version" + logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url") + val conn = URI(url).toURL().openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 120_000 + conn.instanceFollowRedirects = true + conn.setRequestProperty("Accept", "application/vnd.github+json") + try { + val code = conn.responseCode + if (code !in 200..299) throw GradleException("Failed to fetch pinned Kilo CLI release metadata: HTTP $code from $url") + val body = conn.inputStream.bufferedReader().use { it.readText() } + val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray + ?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name } + ?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull + ?: throw GradleException("Pinned Kilo CLI release $version did not include $name") + if (!digest.matches(DIGEST)) throw GradleException("Pinned Kilo CLI release $version asset $name has invalid digest") + return digest + } finally { + conn.disconnect() + } + } + private fun download(url: String, file: File) { logger.lifecycle("Downloading pinned Kilo CLI from $url") val conn = URI(url).toURL().openConnection() as HttpURLConnection @@ -99,6 +144,30 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { } } + private fun verify(file: File, digest: String) { + val actual = sum(file) + if (actual == digest) return + if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}") + throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual") + } + + private fun matches(file: File, digest: String) = file.isFile && sum(file) == digest + + private fun sum(file: File) = "sha256:${sha256(file)}" + + private fun sha256(file: File): String { + val md = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val n = input.read(buffer) + if (n < 0) break + md.update(buffer, 0, n) + } + } + return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } + } + private fun extract(file: File, dir: File) { if (file.name.endsWith(".zip")) { ZipInputStream(file.inputStream().buffered()).use { zip -> diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 7edea34c4a7..35db7427dde 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -82,10 +82,10 @@ class KiloAppService internal constructor( } private fun onState(state: KiloAppStateDto) { - _state.value = state val version = state.downloadVersion val platform = state.downloadPlatform if (version != null && platform != null) info = CoreInfo(version, platform) + _state.value = state if (state.status == KiloAppStatusDto.READY) refreshModelFavoritesAsync() } From 4fff14dc2995f45cb7f97bce34f1a37e3b0283c5 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 6 Jul 2026 16:58:43 -0400 Subject: [PATCH 4/7] fix(jetbrains): align runtime CLI download release flow --- .github/workflows/publish-jetbrains.yml | 10 +----- .kilo/skills/release-jetbrains/SKILL.md | 35 ++++++++++++++++++- CONTRIBUTING.md | 4 +-- .../architecture/jetbrains-plugin.md | 4 +-- packages/kilo-jetbrains/AGENTS.md | 4 +-- packages/kilo-jetbrains/RELEASING.md | 2 ++ .../kilo-jetbrains/backend/build.gradle.kts | 4 +++ .../kilocode/backend/cli/KiloCliDownloader.kt | 28 ++++++++++++++- .../backend/cli/KiloCliDownloaderTest.kt | 33 +++++++++++++++++ .../main/kotlin/GenerateOpenApiSpecTask.kt | 33 ++++++++++++++++- 10 files changed, 139 insertions(+), 18 deletions(-) diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index a5d7b51576f..ba43fb4124a 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -125,19 +125,11 @@ jobs: JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} - - name: Prepare CLI resources - working-directory: packages/kilo-jetbrains - run: bun script/build.ts --production --prepare-cli - env: - KILO_VERSION: ${{ steps.release.outputs.version }} - KILO_CHANNEL: ${{ steps.release.outputs.cli_channel }} - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - - name: Verify plugin working-directory: packages/kilo-jetbrains run: ./gradlew verifyPlugin -Pproduction=true -Pkilo.channel="$CHANNEL" env: + GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.release.outputs.version }} CHANNEL: ${{ steps.release.outputs.marketplace_channel }} diff --git a/.kilo/skills/release-jetbrains/SKILL.md b/.kilo/skills/release-jetbrains/SKILL.md index c84718e439e..b34ec79c85e 100644 --- a/.kilo/skills/release-jetbrains/SKILL.md +++ b/.kilo/skills/release-jetbrains/SKILL.md @@ -34,7 +34,40 @@ Accepted specs: | `x.y.z-rc.n` | Explicit RC release. | | `x.y.z` | Explicit stable release. | -Show the resolved `version`, `kind`, and default `fromTagDefault` to the user and ask for confirmation before continuing. +Show the resolved `version`, `kind`, and default `fromTagDefault` to the user. + +## CLI Pin Verification + +Before dispatching prepare, verify the JetBrains plugin is pinned to the intended Kilo Core release. The plugin downloads the CLI version from `packages/kilo-jetbrains/package.json`, not from the JetBrains plugin version. + +Read the pinned CLI version: + +```bash +bun -e 'const p=require("./packages/kilo-jetbrains/package.json"); console.log(p.version)' +``` + +Verify the matching GitHub Release exists and includes every runtime asset the backend may download: + +```bash +cli_version="7.4.1" +gh release view "v${cli_version}" --repo Kilo-Org/kilocode --json assets \ + --jq '.assets[].name' | sort +``` + +Expected assets: + +```text +kilo-darwin-arm64.zip +kilo-darwin-x64.zip +kilo-linux-arm64.tar.gz +kilo-linux-x64.tar.gz +kilo-windows-arm64.zip +kilo-windows-x64.zip +``` + +If the pin is stale or the release assets are missing, stop and ask the user to update `packages/kilo-jetbrains/package.json` on `main` before dispatching prepare. The prepare workflow tags `origin/main`, so the pin must already be reviewed and merged before the release tag is created. + +Show the resolved JetBrains plugin version, release kind, default `fromTagDefault`, pinned CLI version, and CLI release asset status to the user, then ask for confirmation before continuing. ## Prepare Workflow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61ee8f2e57e..d78e6dd9e49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,10 +126,10 @@ Requires Java 21 (see [Prerequisites](#prerequisites)). From `packages/kilo-jetb ```bash ./gradlew typecheck # Compile-check all Kotlin sources ./gradlew test # Run all tests (backend + frontend) -./gradlew --no-configuration-cache runIdeSplitMode # Launch local split-mode sandbox and prepare the CLI +./gradlew --no-configuration-cache runIdeSplitMode # Launch local split-mode sandbox; backend downloads the pinned CLI ``` -Use `./gradlew runIde` only for a monolithic sandbox; prepare the CLI first with `bun run build --prepare-cli`. +Use `./gradlew runIde` only for a monolithic sandbox. JetBrains dev runs do not build or bundle CLI binaries; the backend downloads the pinned release at connect time. Or via the root turbo filter to run only JetBrains checks from the repo root: diff --git a/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md b/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md index bbfa42efc4d..cc537bd4c9c 100644 --- a/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md +++ b/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md @@ -131,10 +131,10 @@ JetBrains Kotlin toolchain is Java 21. Gradle commands report missing or incompa | Typecheck | `./gradlew typecheck` | | Tests | `./gradlew test` | | Full plugin build | `bun run build` | -| Gradle plugin assembly with prepared CLI binaries | `./gradlew buildPlugin` | +| Gradle plugin assembly with pinned CLI download | `./gradlew buildPlugin` | | Split-mode sandbox | `./gradlew --no-configuration-cache runIdeSplitMode` | | Split backend sandbox | `./gradlew --no-configuration-cache runIdeBackend` | -| Monolithic sandbox IDE | `./gradlew runIde` after `bun run build --prepare-cli` | +| Monolithic sandbox IDE | `./gradlew runIde` | Run `Plugin DevKit | Code | Frontend and Backend API Usage` inspection when moving code across split boundary. diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 05dbd6c0d10..323b158f6d7 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -191,14 +191,14 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi - **Marketplace version build**: Use `script/build-version.sh ` from `packages/kilo-jetbrains/` to clean, build, sign, and verify the JetBrains Marketplace plugin ZIP. Pass `--skip-verification` only when explicitly needed. - **Test version build**: If the user asks for a JetBrains test build, still require a version and use `script/build-version.sh --skip-signing --skip-verification` from `packages/kilo-jetbrains/` so no signing secrets are needed. Add `--skip-clean` only when the user wants a faster incremental test build. -- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. Does NOT require CLI binaries. +- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. A cold build downloads the pinned CLI release via `generateOpenApiSpec` and needs network access; Gradle-cached incremental runs skip the download. It does not bundle per-platform CLI binaries. - **Full build**: `bun run build` from `packages/kilo-jetbrains/` (runs Gradle `buildPlugin`). - **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/`. - **Java checks**: Do not run `java -version` as a routine preflight. Gradle commands already fail clearly when Java is missing or incompatible; check Java only when diagnosing that failure mode. - **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root. - **Run split mode**: `./gradlew --no-configuration-cache runIdeSplitMode` or the checked-in `Run IDE (Split Mode)` configuration — launches backend and frontend locally. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`). - **Run split backend**: `./gradlew --no-configuration-cache runIdeBackend` — if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting. -- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries. +- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does not build or bundle CLI binaries; the backend downloads the pinned release at connect time. ### CLI/SDK Change Awareness diff --git a/packages/kilo-jetbrains/RELEASING.md b/packages/kilo-jetbrains/RELEASING.md index 47d65e0f5ce..a9e844b86aa 100644 --- a/packages/kilo-jetbrains/RELEASING.md +++ b/packages/kilo-jetbrains/RELEASING.md @@ -8,6 +8,8 @@ The published code comes from `jetbrains/v`. Marketplace and GitHub rel Maintainers can use the Kilo `release-jetbrains` skill to drive this process from a version request such as `next rc` or an explicit version. The skill resolves and confirms the version, dispatches and watches the prepare workflow, helps produce a filtered human-readable JetBrains/CLI changelog draft, commits the reviewed changelog to the release PR, and watches publishing after the PR is merged. +JetBrains plugin builds and runtime downloads use the Kilo Core version pinned in `packages/kilo-jetbrains/package.json`, so verify that pin points at a published `v` release before creating the release tag. + The skill lives at `.kilo/skills/release-jetbrains/SKILL.md`. It does not move or recreate release tags, and merge permission is only required if the user explicitly asks the skill to merge the release PR automatically. ## Create Release Tag And PR diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index f6660399878..7421a38b88e 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -40,6 +40,10 @@ val writeKiloProperties by tasks.registering(WriteProperties::class) { val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) { description = "Generate CLI OpenAPI spec into the build directory" cliVersion.set(pinnedCliVersion) + token.set( + providers.environmentVariable("GH_TOKEN") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + ) cacheDir.set(layout.buildDirectory.dir("cli-cache")) spec.set(rawSpec) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index b0e75d42488..73630a7d20a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -12,10 +12,12 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import java.io.File import java.security.MessageDigest +import java.time.Instant import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream import kotlin.math.roundToInt @@ -88,8 +90,21 @@ class KiloCliDownloader( .build() http.newCall(request).execute().use { response -> if (!response.isSuccessful) { - throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: HTTP ${response.code}") + val info = rate(response) + val body = runCatching { response.body?.string() }.getOrNull()?.take(500) + val detail = if (body.isNullOrBlank()) "" else ": $body" + if (limited(response)) { + log.warn("GitHub API rate limit hit fetching Kilo CLI $version metadata from $url ($info)$detail") + throw IllegalStateException( + "GitHub API rate limit exceeded while resolving Kilo CLI $version ($info)$detail" + ) + } + log.warn("Failed to fetch Kilo CLI $version metadata from $url: HTTP ${response.code} ($info)$detail") + throw IllegalStateException( + "Failed to fetch Kilo CLI release metadata for $version: HTTP ${response.code} ($info)$detail" + ) } + log.debug { "GitHub metadata OK for Kilo CLI $version (${rate(response)})" } val body = response.body?.string() ?: throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: empty response body") val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray @@ -103,6 +118,17 @@ class KiloCliDownloader( } } + private fun rate(response: Response): String { + val reset = response.header("X-RateLimit-Reset") + ?.toLongOrNull() + ?.let { Instant.ofEpochSecond(it).toString() } + return "limit=${response.header("X-RateLimit-Limit")} remaining=${response.header("X-RateLimit-Remaining")} " + + "used=${response.header("X-RateLimit-Used")} reset=$reset retryAfter=${response.header("Retry-After")}" + } + + private fun limited(response: Response) = + response.code == 429 || (response.code == 403 && response.header("X-RateLimit-Remaining") == "0") + private fun download(version: String, platform: String, ext: String, file: File, onProgress: (CliDownload) -> Unit) { val url = url(version, platform, ext) log.info("Downloading Kilo CLI $version for $platform from $url") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index eea3cda1c55..6a2e94bc02b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -107,6 +107,39 @@ class KiloCliDownloaderTest { } } + @Test + fun `reports github api rate limits while resolving metadata`() = runBlocking { + MockWebServer().use { server -> + val reset = 1_800_000_000L + server.enqueue( + MockResponse() + .setResponseCode(403) + .setHeader("X-RateLimit-Limit", "60") + .setHeader("X-RateLimit-Remaining", "0") + .setHeader("X-RateLimit-Used", "60") + .setHeader("X-RateLimit-Reset", reset.toString()) + .setBody("API rate limit exceeded") + ) + val log = TestLog() + + val ex = assertFailsWith { + KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + + assertContains(ex.message.orEmpty(), "GitHub API rate limit exceeded") + assertContains(ex.message.orEmpty(), "remaining=0") + assertContains(ex.message.orEmpty(), "reset=2027-01-15T08:00:00Z") + assertTrue(log.messages.any { it.contains("GitHub API rate limit hit") && it.contains("remaining=0") }) + assertEquals("/api/v1.2.3", server.takeRequest().path) + assertEquals(1, server.requestCount) + } + } + private fun archive(): ByteArray { val files = mapOf( "bin/${KiloCliPlatform.exe()}" to "#!/bin/sh\n".toByteArray(), diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt index 0d1d65ecb87..de463945f87 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -20,6 +20,7 @@ import java.io.File import java.net.HttpURLConnection import java.net.URI import java.security.MessageDigest +import java.time.Instant import java.util.zip.ZipInputStream import javax.inject.Inject @@ -36,6 +37,9 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { @get:Input abstract val cliVersion: Property + @get:Internal + abstract val token: Property + @get:Internal abstract val cacheDir: DirectoryProperty @@ -112,9 +116,25 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { conn.readTimeout = 120_000 conn.instanceFollowRedirects = true conn.setRequestProperty("Accept", "application/vnd.github+json") + token.getOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { conn.setRequestProperty("Authorization", "Bearer $it") } try { val code = conn.responseCode - if (code !in 200..299) throw GradleException("Failed to fetch pinned Kilo CLI release metadata: HTTP $code from $url") + if (code !in 200..299) { + val info = rate(conn) + val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } } + .getOrNull() + ?.take(500) + val detail = if (body.isNullOrBlank()) "" else ": $body" + if (limited(conn, code)) { + throw GradleException( + "GitHub API rate limit exceeded while fetching pinned Kilo CLI release metadata ($info)$detail" + ) + } + throw GradleException("Failed to fetch pinned Kilo CLI release metadata: HTTP $code from $url ($info)$detail") + } val body = conn.inputStream.bufferedReader().use { it.readText() } val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray ?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name } @@ -127,6 +147,17 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { } } + private fun rate(conn: HttpURLConnection): String { + val reset = conn.getHeaderField("X-RateLimit-Reset") + ?.toLongOrNull() + ?.let { Instant.ofEpochSecond(it).toString() } + return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " + + "used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}" + } + + private fun limited(conn: HttpURLConnection, code: Int) = + code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0") + private fun download(url: String, file: File) { logger.lifecycle("Downloading pinned Kilo CLI from $url") val conn = URI(url).toURL().openConnection() as HttpURLConnection From 33ddc728ac5b2ca1db41f33396e612d30f8059ec Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 6 Jul 2026 17:01:18 -0400 Subject: [PATCH 5/7] fix(jetbrains): avoid hashing cached CLI archive --- .../main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index 73630a7d20a..f7a6e063819 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -48,7 +48,7 @@ class KiloCliDownloader( val ext = KiloCliPlatform.archive(platform) val archive = File(dir, "kilo-$platform.$ext") - if (!force && exe.isFile && cached != null && cached.matches(DIGEST) && matches(archive, cached)) { + if (!force && exe.isFile && cached != null && cached.matches(DIGEST)) { log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") if (!SystemInfo.isWindows) exe.setExecutable(true) return@withContext exe @@ -169,8 +169,6 @@ class KiloCliDownloader( throw IllegalStateException("Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual") } - private fun matches(file: File, digest: String) = file.isFile && sum(file) == digest - private fun sum(file: File) = "sha256:${sha256(file)}" private fun sha256(file: File): String { From 62c41e21c6cff2ef9686de5ef678de33173c54bc Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 7 Jul 2026 08:48:21 -0400 Subject: [PATCH 6/7] fix(jetbrains): prune old CLI binaries from the download cache Each pinned CLI version was cached under the IDE system dir forever, so old versions accumulated across plugin updates. After a version resolves successfully, delete every other cached version and drop the downloaded archive after extraction. Reinstall still deletes and re-downloads the active version. --- .changeset/jetbrains-prune-old-cli.md | 5 ++ .../kilocode/backend/cli/KiloCliDownloader.kt | 22 +++++++++ .../backend/cli/KiloCliDownloaderTest.kt | 46 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 .changeset/jetbrains-prune-old-cli.md diff --git a/.changeset/jetbrains-prune-old-cli.md b/.changeset/jetbrains-prune-old-cli.md new file mode 100644 index 00000000000..07600c3f7eb --- /dev/null +++ b/.changeset/jetbrains-prune-old-cli.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Remove old JetBrains CLI binaries so they no longer accumulate in the IDE cache. Only the active version is kept, the downloaded archive is deleted after extraction, and reinstalling re-downloads a fresh binary. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index f7a6e063819..b50204f2ed5 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -51,6 +51,7 @@ class KiloCliDownloader( if (!force && exe.isFile && cached != null && cached.matches(DIGEST)) { log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") if (!SystemInfo.isWindows) exe.setExecutable(true) + prune(version) return@withContext exe } @@ -75,8 +76,12 @@ class KiloCliDownloader( extract(archive, dir) if (!exe.isFile) throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}") if (!SystemInfo.isWindows) exe.setExecutable(true) + if (archive.exists() && !archive.delete()) { + log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}") + } done.writeText("$digest\n") onProgress(CliDownload(100, version, platform)) + prune(version) exe } @@ -223,6 +228,23 @@ class KiloCliDownloader( } } + /** + * Delete every cached CLI version under [root] except [keep]. Each plugin update pins a new + * CLI version, so without this old versions would accumulate in the IDE system directory + * indefinitely. Runs only after the active version resolved successfully so a failed download + * never wipes the last working copy. + */ + private fun prune(keep: String) { + val entries = root.listFiles() ?: return + for (entry in entries) { + if (!entry.isDirectory || entry.name == keep) continue + log.info("Removing stale Kilo CLI version ${entry.absolutePath}") + if (!entry.deleteRecursively()) { + log.warn("Failed to remove stale Kilo CLI version ${entry.absolutePath}") + } + } + } + private fun url(version: String, platform: String, ext: String) = "${baseUrl.trimEnd('/')}/v$version/kilo-$platform.$ext" } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index 6a2e94bc02b..b2cc0369ef2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -87,6 +87,52 @@ class KiloCliDownloaderTest { } } + @Test + fun `prunes stale versions and removes the extracted archive`() = runBlocking { + MockWebServer().use { server -> + val stale = File(File(dir, "0.0.1"), KiloCliPlatform.current()) + assertTrue(stale.mkdirs()) + File(stale, "leftover").writeText("old") + + val bytes = archive() + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + val cli = KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + + assertTrue(cli.isFile) + assertFalse(File(dir, "0.0.1").exists()) + val archived = File(cli.parentFile.parentFile, "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}") + assertFalse(archived.exists()) + } + } + + @Test + fun `forced resolve re-downloads and keeps only the active version`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive() + repeat(2) { + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + } + val cli = KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ) + cli.resolve("1.2.3") + assertEquals(2, server.requestCount) + + val forced = cli.resolve("1.2.3", force = true) + assertTrue(forced.isFile) + assertEquals(4, server.requestCount) + assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory }?.map { it.name }) + } + } + @Test fun `rejects cli archive with mismatched digest`() = runBlocking { MockWebServer().use { server -> From bd29b94d20bb6639d55f6ac009c793f6e313030d Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 7 Jul 2026 08:52:25 -0400 Subject: [PATCH 7/7] fix(jetbrains): report exact CLI release resolution failure The collapsed metadata lookup threw a misleading "did not include" error whether the asset was absent, had no digest, or had a malformed digest. Split into distinct, logged messages so the actual cause is clear, including the nullable-digest case where GitHub has not published a checksum. --- .../kilocode/backend/cli/KiloCliDownloader.kt | 21 +++++++-- .../backend/cli/KiloCliDownloaderTest.kt | 46 +++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index b50204f2ed5..269331e13ce 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -85,6 +85,11 @@ class KiloCliDownloader( exe } + private fun fail(message: String): Nothing { + log.warn(message) + throw IllegalStateException(message) + } + private fun asset(version: String, platform: String, ext: String): String { val name = "kilo-$platform.$ext" val url = "${api.trimEnd('/')}/v$version" @@ -112,12 +117,18 @@ class KiloCliDownloader( log.debug { "GitHub metadata OK for Kilo CLI $version (${rate(response)})" } val body = response.body?.string() ?: throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: empty response body") - val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray - ?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name } - ?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull - ?: throw IllegalStateException("Kilo CLI release $version did not include $name") + val assets = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray + val entry = assets?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name } + if (entry == null) { + val names = assets?.mapNotNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull }?.joinToString(", ") + fail("Kilo CLI release $version has no asset named $name (available assets: ${names ?: "none"})") + } + val digest = entry.jsonObject["digest"]?.jsonPrimitive?.contentOrNull + if (digest == null) { + fail("Kilo CLI release $version asset $name has no digest yet; GitHub has not published a SHA-256 checksum for it") + } if (!digest.matches(DIGEST)) { - throw IllegalStateException("Kilo CLI release $version asset $name has invalid digest") + fail("Kilo CLI release $version asset $name has a malformed digest '$digest'; expected sha256:<64 hex chars>") } return digest } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index b2cc0369ef2..face684e5f7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -153,6 +153,52 @@ class KiloCliDownloaderTest { } } + @Test + fun `fails clearly and logs when the release has no matching asset`() = runBlocking { + MockWebServer().use { server -> + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"assets":[{"name":"other.zip","digest":"sha256:${"a".repeat(64)}"}]}""" + ) + ) + val log = TestLog() + val ex = assertFailsWith { + KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + val name = "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}" + assertContains(ex.message.orEmpty(), "has no asset named $name") + assertContains(ex.message.orEmpty(), "other.zip") + assertTrue(log.messages.any { it.contains("has no asset named $name") }) + } + } + + @Test + fun `fails clearly and logs when the asset has no digest`() = runBlocking { + MockWebServer().use { server -> + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}"}]}""" + ) + ) + val log = TestLog() + val ex = assertFailsWith { + KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + assertContains(ex.message.orEmpty(), "has no digest yet") + assertTrue(log.messages.any { it.contains("has no digest yet") }) + } + } + @Test fun `reports github api rate limits while resolving metadata`() = runBlocking { MockWebServer().use { server ->