diff --git a/.changeset/jetbrains-bundled-cli.md b/.changeset/jetbrains-bundled-cli.md
new file mode 100644
index 0000000000..d0239ff176
--- /dev/null
+++ b/.changeset/jetbrains-bundled-cli.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/kilo-jetbrains": minor
+---
+
+Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
diff --git a/.github/workflows/bundle-jetbrains.yml b/.github/workflows/bundle-jetbrains.yml
new file mode 100644
index 0000000000..e9dced730c
--- /dev/null
+++ b/.github/workflows/bundle-jetbrains.yml
@@ -0,0 +1,270 @@
+# kilocode_change - new file
+name: bundle-jetbrains
+
+on:
+ workflow_dispatch:
+ inputs:
+ pr:
+ description: Merged JetBrains release PR number to bundle
+ required: true
+ type: string
+ merge_commit:
+ description: Merge commit SHA from the reviewed release PR
+ required: true
+ type: string
+
+concurrency:
+ group: bundle-jetbrains-pr-${{ inputs.pr }}
+ cancel-in-progress: false
+
+permissions:
+ actions: read
+ contents: write
+ id-token: write
+ pages: write
+ pull-requests: read
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ bundle:
+ if: github.repository == 'Kilo-Org/kilocode'
+ runs-on: blacksmith-8vcpu-ubuntu-2404
+ steps:
+ - name: Checkout merged release PR
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ ref: ${{ inputs.merge_commit }}
+
+ - name: Setup Bun for validation
+ uses: ./.github/actions/setup-bun
+
+ - name: Validate release PR and tag
+ id: release
+ run: bun script/jetbrains-release-validate.ts --pr "$PR_NUMBER"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ inputs.pr }}
+
+ - name: Save reviewed release metadata
+ run: |
+ cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md"
+ cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties"
+
+ - name: Checkout release tag
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ ref: ${{ steps.release.outputs.tag }}
+
+ - name: Restore reviewed release metadata
+ run: |
+ cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md
+ cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+
+ - name: Setup Bun
+ uses: ./.github/actions/setup-bun
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Install build tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y patchelf zip unzip
+ curl --fail --location \
+ https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
+ --output "$RUNNER_TEMP/zig.tar.xz"
+ echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
+ tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
+ echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
+
+ - name: Validate signing secrets
+ run: |
+ missing=0
+ for name in JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do
+ if [[ -z "${!name}" ]]; then
+ echo "Missing required secret: $name" >&2
+ missing=1
+ fi
+ done
+ exit "$missing"
+ env:
+ JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
+ JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
+ JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
+
+ - name: Build signed bundled plugin
+ working-directory: packages/kilo-jetbrains
+ run: |
+ ./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \
+ -Pproduction=true \
+ -Pkilo.version="$VERSION" \
+ -Pkilo.channel="$CHANNEL" \
+ -Pkilo.cli.bundled=true
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GITHUB_TOKEN: ${{ github.token }}
+ VERSION: ${{ steps.release.outputs.version }}
+ CHANNEL: ${{ steps.release.outputs.marketplace_channel }}
+ JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
+ JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
+ JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
+
+ - name: Resolve bundled archive
+ id: archive
+ run: |
+ mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip")
+ if [[ "${#signed[@]}" -ne 1 ]]; then
+ echo "Expected exactly one signed bundled JetBrains plugin ZIP, found ${#signed[@]}." >&2
+ printf '%s\n' "${signed[@]}" >&2
+ exit 1
+ fi
+ asset="kilo-code-${VERSION}-bundled.zip"
+ dest="packages/kilo-jetbrains/build/release/$asset"
+ mkdir -p "$(dirname "$dest")"
+ cp "${signed[0]}" "$dest"
+ echo "asset=$asset" >> "$GITHUB_OUTPUT"
+ echo "path=$dest" >> "$GITHUB_OUTPUT"
+ env:
+ VERSION: ${{ steps.release.outputs.version }}
+
+ - name: Upload bundled ZIP to GitHub Release
+ run: gh release upload "$TAG" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ steps.release.outputs.tag }}
+ ARCHIVE: ${{ steps.archive.outputs.path }}
+
+ - name: Resolve bundled asset URL
+ id: asset
+ run: |
+ url="$(python3 - <<'PY'
+ import os
+ import urllib.parse
+ repo = os.environ["GITHUB_REPOSITORY"]
+ tag = urllib.parse.quote(os.environ["TAG"], safe="")
+ asset = urllib.parse.quote(os.environ["ASSET"], safe="")
+ print(f"https://github.com/{repo}/releases/download/{tag}/{asset}")
+ PY
+ )"
+ echo "url=$url" >> "$GITHUB_OUTPUT"
+ env:
+ TAG: ${{ steps.release.outputs.tag }}
+ ASSET: ${{ steps.archive.outputs.asset }}
+
+ - name: Generate stable plugin repository XML
+ if: steps.release.outputs.kind == 'stable'
+ run: |
+ mkdir -p pages/jetbrains
+ python3 <<'PY'
+ import html
+ import io
+ import os
+ import zipfile
+ import xml.etree.ElementTree as ET
+
+ archive = os.environ["ARCHIVE"]
+ asset = os.environ["ASSET_URL"]
+ version = os.environ["VERSION"]
+
+ def plugin_xml(path):
+ with zipfile.ZipFile(path) as zip:
+ for name in zip.namelist():
+ if name.endswith("META-INF/plugin.xml"):
+ return zip.read(name)
+ for name in zip.namelist():
+ if not name.endswith(".jar"):
+ continue
+ with zipfile.ZipFile(io.BytesIO(zip.read(name))) as jar:
+ for item in jar.namelist():
+ if item.endswith("META-INF/plugin.xml"):
+ return jar.read(item)
+ raise SystemExit("bundled plugin ZIP did not contain META-INF/plugin.xml")
+
+ root = ET.fromstring(plugin_xml(archive))
+
+ def text(name, default=""):
+ item = root.find(name)
+ return item.text.strip() if item is not None and item.text else default
+
+ def cdata(value):
+ return "", "]]]]>") + "]]>"
+
+ plugin = text("id", "ai.kilocode.jetbrains")
+ name = text("name", "Kilo Code")
+ vendor = text("vendor", "Kilo Code")
+ desc = text("description")
+ notes = text("change-notes")
+ idea = root.find("idea-version")
+ attrs = ""
+ if idea is not None:
+ since = idea.attrib.get("since-build")
+ until = idea.attrib.get("until-build")
+ if since:
+ attrs += f' since-build="{html.escape(since)}"'
+ if until:
+ attrs += f' until-build="{html.escape(until)}"'
+
+ xml = [
+ '',
+ '',
+ f' ',
+ f' {html.escape(name)}',
+ f' {html.escape(vendor)}',
+ f' ',
+ ]
+ if desc:
+ xml.append(f' {cdata(desc)}')
+ if notes:
+ xml.append(f' {cdata(notes)}')
+ xml.extend([' ', '', ''])
+ with open("pages/jetbrains/updatePlugins.xml", "w", encoding="utf-8") as file:
+ file.write("\n".join(xml))
+ PY
+ env:
+ ARCHIVE: ${{ steps.archive.outputs.path }}
+ ASSET_URL: ${{ steps.asset.outputs.url }}
+ VERSION: ${{ steps.release.outputs.version }}
+
+ - name: Configure Pages
+ if: steps.release.outputs.kind == 'stable'
+ uses: actions/configure-pages@v5
+
+ - name: Upload Pages artifact
+ if: steps.release.outputs.kind == 'stable'
+ uses: actions/upload-pages-artifact@v4
+ with:
+ path: pages
+
+ - name: Deploy Pages
+ if: steps.release.outputs.kind == 'stable'
+ id: deployment
+ uses: actions/deploy-pages@v4
+
+ - name: Upload workflow artifact
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: kilo-jetbrains-bundled-${{ steps.release.outputs.version }}
+ path: |
+ packages/kilo-jetbrains/build/release/*.zip
+ pages/jetbrains/updatePlugins.xml
+ if-no-files-found: ignore
diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml
index ba43fb4124..9dc7ef0d0c 100644
--- a/.github/workflows/publish-jetbrains.yml
+++ b/.github/workflows/publish-jetbrains.yml
@@ -23,6 +23,7 @@ concurrency:
cancel-in-progress: false
permissions:
+ actions: write
contents: write
pull-requests: read
@@ -199,6 +200,18 @@ jobs:
ARCHIVE: ${{ steps.archive.outputs.path }}
NOTES: packages/kilo-jetbrains/build/release-notes.md
+ - name: Dispatch bundled GitHub release build
+ run: |
+ gh workflow run bundle-jetbrains.yml \
+ --repo "$GITHUB_REPOSITORY" \
+ --ref main \
+ -f pr="$PR_NUMBER" \
+ -f merge_commit="$MERGE_COMMIT"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }}
+ MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.merge_commit }}
+
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md
index 714f92feb0..05a80bf50c 100644
--- a/packages/kilo-jetbrains/AGENTS.md
+++ b/packages/kilo-jetbrains/AGENTS.md
@@ -157,6 +157,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
- CLI process spawning, download, extraction, and lifecycle belong in `backend`.
- By default, 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` and `cli.pinned` for split-mode RPC and runtime use.
+- Bundled release builds pass `-Pkilo.cli.bundled=true` while keeping `kilo.cli.pinned=true`. This build-only flag stages all pinned CLI release assets into `kilo-cli.zip`; runtime detects that resource and extracts only the current platform instead of downloading. Do not add a `cli.bundled` key to `kilo.properties` or repurpose `kilo.cli.pinned=false` for public bundled releases.
- For release questions, use the `release-jetbrains` skill and reference `.kilo/skills/release-jetbrains/SKILL.md`; it verifies the CLI pin before creating immutable `jetbrains/v*` tags.
- 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")`.
diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md
index e5452e7ddd..754202c3c0 100644
--- a/packages/kilo-jetbrains/README.md
+++ b/packages/kilo-jetbrains/README.md
@@ -78,7 +78,7 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-.zip
## Releasing
-See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, and how to install RC builds via the custom plugin repository.
+See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, how to install RC builds, and how the signed bundled CLI build is published to the GitHub-hosted stable plugin repository.
---
diff --git a/packages/kilo-jetbrains/RELEASE_TODO.md b/packages/kilo-jetbrains/RELEASE_TODO.md
index b74103894b..ee515adaa7 100644
--- a/packages/kilo-jetbrains/RELEASE_TODO.md
+++ b/packages/kilo-jetbrains/RELEASE_TODO.md
@@ -14,6 +14,8 @@
- Create a JetBrains Marketplace permanent token from Marketplace `My Tokens`.
- Add `JETBRAINS_MARKETPLACE_TOKEN` to GitHub Actions secrets or the protected environment.
- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases for `jetbrains/v*` tags.
+- Confirm `GITHUB_TOKEN` has `actions: write`, `pages: write`, and `id-token: write` permission for dispatching bundled releases and publishing the stable GitHub Pages plugin repository.
+- Configure GitHub Pages for this repository with source set to GitHub Actions.
- Confirm `KILO_MAINTAINER_APP_ID` and `KILO_MAINTAINER_APP_SECRET` are available to create release PRs and immediate release tags.
- Optionally create a protected `jetbrains-marketplace` GitHub Environment with required reviewers.
- If using an environment, move the Marketplace and signing secrets there and set the workflow job environment.
@@ -35,6 +37,7 @@
- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR.
- Merge the release PR to trigger publish from `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`.
- Watch the `publish-jetbrains` workflow.
+- Confirm the follow-up `bundle-jetbrains` workflow completes and attaches `kilo-code-x.y.z-rc.n-bundled.zip` to the prerelease.
- Download and retain the workflow artifact if needed.
- Confirm the update appears on the JetBrains Marketplace `eap` channel.
- Confirm the GitHub Release for the `jetbrains/vx.y.z-rc.n` tag exists and contains the JetBrains plugin ZIP asset.
@@ -48,5 +51,6 @@
- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR.
- Merge the release PR to trigger publish from `jetbrains/vx.y.z`.
- Watch the `publish-jetbrains` workflow.
+- Confirm the follow-up `bundle-jetbrains` workflow completes, attaches `kilo-code-x.y.z-bundled.zip`, and updates `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`.
- Confirm the update appears on the default JetBrains Marketplace channel.
- Confirm the GitHub Release for the `jetbrains/vx.y.z` tag exists and contains the JetBrains plugin ZIP asset.
diff --git a/packages/kilo-jetbrains/RELEASING.md b/packages/kilo-jetbrains/RELEASING.md
index 606065a27d..f6314c4f06 100644
--- a/packages/kilo-jetbrains/RELEASING.md
+++ b/packages/kilo-jetbrains/RELEASING.md
@@ -132,6 +132,16 @@ Publishing behavior:
The workflow checks out `jetbrains/v` for verification, signing, and Marketplace publishing. It overlays the reviewed `packages/kilo-jetbrains/gradle.properties` and `packages/kilo-jetbrains/CHANGELOG.md` from the merged PR before rendering release notes and before `publishPlugin`, so the Marketplace plugin version, Marketplace notes, and GitHub Release use the reviewed metadata.
+After Marketplace publishing succeeds, `publish-jetbrains` dispatches `bundle-jetbrains`. The bundled workflow rebuilds the same `jetbrains/v` tag with `-Pkilo.cli.bundled=true`, signs and verifies the all-platform plugin ZIP, then uploads `kilo-code--bundled.zip` to the same GitHub Release. Bundled builds keep `kilo.cli.pinned=true`; the build flag only embeds the pinned CLI release assets so runtime extracts the bundled current-platform CLI instead of downloading it.
+
+Stable bundled releases also publish the GitHub Pages custom plugin repository XML:
+
+```text
+https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml
+```
+
+RC bundled ZIPs are attached to prereleases for install-from-disk testing, but they do not update the stable custom repository XML.
+
## Installing RC Builds
RC builds are published to the `eap` channel. To get them in IntelliJ IDEA:
diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts
index 374aa08a85..77d014030d 100644
--- a/packages/kilo-jetbrains/backend/build.gradle.kts
+++ b/packages/kilo-jetbrains/backend/build.gradle.kts
@@ -22,6 +22,7 @@ val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
val generatedCli = layout.buildDirectory.dir("generated/kilo-cli-res")
val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true)
val repoCli = pinned.map { !it }
+val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false)
val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode")
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
@@ -32,11 +33,15 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector
sourceSets {
main {
resources.srcDir(generatedProps)
- if (repoCli.get()) resources.srcDir(generatedCli)
+ if (repoCli.get() || bundled.get()) resources.srcDir(generatedCli)
kotlin.srcDir(generatedApi)
}
}
+if (repoCli.get() && bundled.get()) {
+ error("kilo.cli.bundled=true requires kilo.cli.pinned=true; do not combine release CLI bundling with local repo CLI mode.")
+}
+
val writeKiloProperties by tasks.registering(WriteProperties::class) {
description = "Write pinned Kilo CLI properties"
val out = generatedProps.map { it.file("kilo.properties") }
@@ -88,6 +93,17 @@ val stageRepoCli by tasks.registering(StageRepoCliTask::class) {
outputs.upToDateWhen { false }
}
+val stageBundledCli by tasks.registering(StageBundledCliTask::class) {
+ description = "Stage all pinned Kilo CLI release assets into backend resources"
+ cliVersion.set(pinnedCliVersion)
+ token.set(
+ providers.environmentVariable("GH_TOKEN")
+ .orElse(providers.environmentVariable("GITHUB_TOKEN"))
+ )
+ cacheDir.set(layout.buildDirectory.dir("cli-cache"))
+ archive.set(generatedCli.map { it.file("kilo-cli.zip") })
+}
+
val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) {
description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation"
dependsOn(generateOpenApiSpec)
@@ -143,12 +159,14 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
tasks.named("compileKotlin") {
dependsOn(fixGeneratedApi, writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
+ if (bundled.get()) dependsOn(stageBundledCli)
inputs.dir(generatedApi)
}
tasks.named("processResources") {
dependsOn(writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
+ if (bundled.get()) dependsOn(stageBundledCli)
}
tasks.named("compileTestKotlin") {
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 ce7b9fd256..9425f0779f 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
@@ -126,8 +126,8 @@ class KiloBackendCliManager(
private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File {
val force = forceExtract
forceExtract = false
- if (!KiloProps.pinned()) {
- if (force) log.info("Force re-extracting local repo CLI ${KiloProps.cliVersion()}")
+ if (KiloRepoCli.available()) {
+ if (force) log.info("Force re-extracting bundled CLI ${KiloProps.cliVersion()}")
val cli = KiloRepoCli.extract(force)
onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current()))
return cli
diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt
index a46ea79506..42dac82365 100644
--- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt
+++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt
@@ -10,17 +10,22 @@ import java.io.OutputStream
import java.util.zip.ZipInputStream
object KiloRepoCli {
+ private const val ARCHIVE = "kilo-cli.zip"
+
+ fun available(): Boolean = KiloRepoCli::class.java.classLoader.getResource(ARCHIVE) != null
+
suspend fun extract(force: Boolean): File = extract(
force = force,
- root = File(PathManager.getSystemPath(), "kilo/repo-cli"),
+ root = File(PathManager.getSystemPath(), "kilo/repo-cli/${KiloProps.cliVersion()}"),
source = {
- KiloRepoCli::class.java.classLoader.getResourceAsStream("kilo-cli.zip")
- ?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with kilo.cli.pinned=false")
+ KiloRepoCli::class.java.classLoader.getResourceAsStream(ARCHIVE)
+ ?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with bundled CLI resources")
},
)
internal suspend fun extract(force: Boolean, root: File, source: () -> InputStream): File = withContext(Dispatchers.IO) {
- val exe = File(root, "bin/${KiloCliPlatform.exe()}")
+ val platform = KiloCliPlatform.current()
+ val exe = File(root, "$platform/bin/${KiloCliPlatform.exe()}")
val done = File(root, ".complete")
if (!force && done.isFile && exe.isFile) {
if (!SystemInfo.isWindows) exe.setExecutable(true)
@@ -38,21 +43,42 @@ object KiloRepoCli {
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
- write(root, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
+ val path = select(root, entry.name, platform)
+ if (path != null) write(root, path, entry.isDirectory) { out -> zip.copyTo(out) }
zip.closeEntry()
}
}
}
- if (!exe.isFile) throw IllegalStateException("Local repo CLI archive did not contain bin/${KiloCliPlatform.exe()}")
+ if (!exe.isFile) throw IllegalStateException("Bundled CLI archive did not contain $platform/bin/${KiloCliPlatform.exe()}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
done.writeText("ok\n")
return@withContext exe
}
+ private fun select(dir: File, name: String, platform: String): String? {
+ check(dir, name)
+ val path = name.replace('\\', '/')
+ val prefix = "$platform/"
+ if (path.startsWith(prefix)) return path
+ if (path.startsWith("bin/")) return "$platform/$path"
+ return null
+ }
+
+ private fun check(dir: File, name: String) {
+ val raw = name.replace('\\', '/')
+ if (raw.startsWith("/")) throw IllegalStateException("Archive entry escapes target directory: $name")
+ val parts = raw.split('/').filter { it.isNotEmpty() }
+ if (parts.any { it == ".." }) throw IllegalStateException("Archive entry escapes target directory: $name")
+ val target = File(dir, name).canonicalFile
+ val base = dir.canonicalFile
+ if (target != base && !target.path.startsWith(base.path + File.separator)) {
+ throw IllegalStateException("Archive entry escapes target directory: $name")
+ }
+ }
+
private fun write(dir: File, name: String, directory: Boolean, copy: (OutputStream) -> Unit) {
- val path = if (name.startsWith("bin/")) name else "bin/$name"
- val target = File(dir, path).canonicalFile
+ val target = File(dir, name).canonicalFile
val base = dir.canonicalFile
if (target != base && !target.path.startsWith(base.path + File.separator)) {
throw IllegalStateException("Archive entry escapes target directory: $name")
diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt
index 4c2f140053..28f22a420c 100644
--- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt
+++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt
@@ -12,6 +12,7 @@ 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 KiloRepoCliTest {
@@ -38,6 +39,20 @@ class KiloRepoCliTest {
assertEquals("#!/bin/new\n", forced.readText())
}
+ @Test
+ fun `extracts only current platform from multi platform archive`() = runBlocking {
+ val platform = KiloCliPlatform.current()
+ val other = if (platform == "windows-x64") "darwin-arm64" else "windows-x64"
+ val cli = KiloRepoCli.extract(false, dir) {
+ ByteArrayInputStream(multi(platform, other))
+ }
+
+ assertTrue(cli.isFile)
+ assertEquals("current", cli.readText())
+ assertFalse(File(dir, "$other/bin/kilo.exe").exists())
+ assertFalse(File(dir, "$other/bin/kilo").exists())
+ }
+
@Test
fun `rejects archive entries that escape root`() = runBlocking {
val ex = assertFailsWith {
@@ -66,4 +81,17 @@ class KiloRepoCliTest {
}
return out.toByteArray()
}
+
+ private fun multi(platform: String, other: String): ByteArray {
+ val out = ByteArrayOutputStream()
+ ZipOutputStream(out).use { zip ->
+ zip.putNextEntry(ZipEntry("$platform/bin/${KiloCliPlatform.exe()}"))
+ zip.write("current".toByteArray())
+ zip.closeEntry()
+ zip.putNextEntry(ZipEntry("$other/bin/kilo.exe"))
+ zip.write("other".toByteArray())
+ zip.closeEntry()
+ }
+ return out.toByteArray()
+ }
}
diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt
new file mode 100644
index 0000000000..c8b7b7625a
--- /dev/null
+++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt
@@ -0,0 +1,221 @@
+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
+import org.gradle.api.GradleException
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+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.TaskAction
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URI
+import java.security.MessageDigest
+import java.time.Instant
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+import java.util.zip.ZipOutputStream
+
+abstract class StageBundledCliTask : 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"
+ private val PLATFORMS = listOf(
+ "darwin-arm64",
+ "darwin-x64",
+ "linux-arm64",
+ "linux-x64",
+ "windows-arm64",
+ "windows-x64",
+ )
+ }
+
+ @get:Input
+ abstract val cliVersion: Property
+
+ @get:Internal
+ abstract val token: Property
+
+ @get:Internal
+ abstract val cacheDir: DirectoryProperty
+
+ @get:OutputFile
+ abstract val archive: RegularFileProperty
+
+ @TaskAction
+ fun run() {
+ val ver = cliVersion.get()
+ val assets = assets(ver)
+ val files = PLATFORMS.associateWith { platform ->
+ val ext = ext(platform)
+ val name = "kilo-$platform.$ext"
+ val digest = assets[name] ?: throw GradleException("Kilo CLI release $ver did not include $name")
+ val file = cacheDir.dir(ver).map { it.dir(platform).file(name) }.get().asFile
+ fetch(ver, platform, name, digest, file)
+ file
+ }
+
+ val out = archive.get().asFile
+ out.parentFile.mkdirs()
+ ZipOutputStream(out.outputStream().buffered()).use { zip ->
+ for ((platform, file) in files) {
+ if (file.name.endsWith(".zip")) {
+ zip(platform, file, zip)
+ continue
+ }
+ tar(platform, file, zip)
+ }
+ }
+ }
+
+ private fun assets(ver: String): Map {
+ val url = "$API/v$ver"
+ logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url")
+ val conn = connect(url)
+ try {
+ val code = conn.responseCode
+ if (code !in 200..299) fail(conn, code, "Failed to fetch pinned Kilo CLI release metadata")
+ val body = conn.inputStream.bufferedReader().use { it.readText() }
+ return JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
+ ?.associate { item ->
+ val obj = item.jsonObject
+ val name = obj["name"]?.jsonPrimitive?.contentOrNull
+ val digest = obj["digest"]?.jsonPrimitive?.contentOrNull
+ if (name.isNullOrBlank() || digest.isNullOrBlank()) return@associate "" to ""
+ name to digest
+ }
+ ?.filter { it.key.isNotEmpty() }
+ ?.mapValues { item ->
+ val digest = item.value
+ if (!digest.matches(DIGEST)) {
+ throw GradleException("Pinned Kilo CLI release $ver asset ${item.key} has invalid digest")
+ }
+ digest
+ }
+ ?: emptyMap()
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ private fun fetch(ver: String, platform: String, name: String, digest: String, file: File) {
+ if (file.isFile && sum(file) == digest) return
+ file.parentFile.mkdirs()
+ val url = "https://github.com/Kilo-Org/kilocode/releases/download/v$ver/$name"
+ logger.lifecycle("Downloading pinned Kilo CLI $platform from $url")
+ val conn = connect(url)
+ try {
+ val code = conn.responseCode
+ if (code !in 200..299) fail(conn, code, "Failed to download pinned Kilo CLI $platform")
+ conn.inputStream.use { input ->
+ file.outputStream().use { output -> input.copyTo(output) }
+ }
+ } finally {
+ conn.disconnect()
+ }
+ verify(file, digest)
+ }
+
+ private fun zip(platform: String, file: File, out: ZipOutputStream) {
+ ZipInputStream(file.inputStream().buffered()).use { zip ->
+ while (true) {
+ val entry = zip.nextEntry ?: break
+ if (!entry.isDirectory) write(out, platform, entry.name) { zip.copyTo(out) }
+ zip.closeEntry()
+ }
+ }
+ }
+
+ private fun tar(platform: String, file: File, out: ZipOutputStream) {
+ TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar ->
+ while (true) {
+ val entry = tar.nextEntry ?: break
+ if (!entry.isDirectory) write(out, platform, entry.name) { tar.copyTo(out) }
+ }
+ }
+ }
+
+ private fun write(out: ZipOutputStream, platform: String, name: String, copy: () -> Unit) {
+ out.putNextEntry(ZipEntry(path(platform, name)))
+ copy()
+ out.closeEntry()
+ }
+
+ private fun path(platform: String, name: String): String {
+ val raw = name.replace('\\', '/')
+ if (raw.startsWith("/")) throw GradleException("Archive entry escapes target directory: $name")
+ val parts = raw.split('/').filter { it.isNotEmpty() && it != "." }
+ if (parts.isEmpty()) throw GradleException("Archive entry is empty: $name")
+ if (parts.any { it == ".." }) throw GradleException("Archive entry escapes target directory: $name")
+ val path = if (parts.first() == "bin") parts else listOf("bin") + parts
+ return "$platform/${path.joinToString("/")}"
+ }
+
+ 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 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 connect(url: String): HttpURLConnection {
+ 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")
+ token.getOrNull()
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
+ return conn
+ }
+
+ private fun fail(conn: HttpURLConnection, code: Int, msg: String): Nothing {
+ 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 staging bundled Kilo CLI ($info)$detail")
+ }
+ throw GradleException("$msg: HTTP $code from ${conn.url} ($info)$detail")
+ }
+
+ 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 ext(platform: String) = if (platform.startsWith("linux-")) "tar.gz" else "zip"
+}
diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt
index f5a77cd532..1f58091ee0 100644
--- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt
+++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt
@@ -28,12 +28,13 @@ abstract class StageRepoCliTask : DefaultTask() {
}
val out = archive.get().asFile
+ val platform = platform()
out.parentFile.mkdirs()
ZipOutputStream(out.outputStream().buffered()).use { zip ->
dir.walkTopDown()
.filter { it.isFile }
.forEach { file ->
- val name = "bin/${file.relativeTo(dir).invariantSeparatorsPath}"
+ val name = "$platform/bin/${file.relativeTo(dir).invariantSeparatorsPath}"
zip.putNextEntry(ZipEntry(name))
file.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
@@ -42,4 +43,20 @@ abstract class StageRepoCliTask : DefaultTask() {
}
private fun exe() = if (System.getProperty("os.name").lowercase().contains("windows")) "kilo.exe" else "kilo"
+
+ 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"
+ }
}
diff --git a/packages/kilo-jetbrains/docs/bundled-release-plan.md b/packages/kilo-jetbrains/docs/bundled-release-plan.md
new file mode 100644
index 0000000000..0ce1390819
--- /dev/null
+++ b/packages/kilo-jetbrains/docs/bundled-release-plan.md
@@ -0,0 +1,64 @@
+# JetBrains Bundled-CLI Release Plan
+
+Ship a signed, all-platform, CLI-bundled build of the Kilo JetBrains plugin to a GitHub-hosted custom plugin repository, as an alternative to the JetBrains Marketplace, which caps plugin ZIPs at 400 MB. The Marketplace build stays lean and downloads the CLI at runtime; the bundled build embeds every platform's CLI so it works offline or on restricted networks.
+
+## Decisions
+
+1. Host `updatePlugins.xml` via GitHub Pages deployed by Actions.
+2. Maintain a single stable custom repo: one `updatePlugins.xml`, updated on stable releases only.
+3. Auto-trigger the bundled workflow after `publish-jetbrains` succeeds.
+4. Decide runtime delivery by presence of the bundled `kilo-cli.zip` resource. Do not add a `kilo.properties` flag, and do not edit committed files for a bundled build.
+
+## Core Principle
+
+- A bundled build uses the same `jetbrains/v` tag, the same source, and `kilo.cli.pinned=true`.
+- The only build difference is the override `-Pkilo.cli.bundled=true`.
+- `kilo.properties` stays byte-identical between Marketplace and bundled builds. The only build-output difference is whether `kilo-cli.zip` is embedded in the backend jar.
+- `kilo.cli.pinned` keeps its existing meaning: which CLI version / OpenAPI source / release guard. It does not control runtime delivery.
+
+## Phase 1: Backend Delivery
+
+- Add `KiloRepoCli.available()` to detect `kilo-cli.zip` on the classpath.
+- Change `KiloBackendCliManager.resolveCli()` to extract when `KiloRepoCli.available()` is true; otherwise download the pinned release asset.
+- Store bundled archives as `/bin/kilo[.exe]` for all six platforms: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `windows-arm64`, `windows-x64`.
+- Extract only the current platform's subtree to disk so users do not store all six binaries locally.
+- Keep path traversal checks for every archive entry.
+- Update repo CLI dev staging to use the same layout.
+
+## Phase 2: Gradle Bundling
+
+- Add a build-only property `kilo.cli.bundled`, defaulting to false.
+- Keep `kilo.cli.pinned=true` for bundled production builds.
+- Add a task that downloads all six pinned CLI release assets from GitHub, verifies their `sha256` digests from release metadata, and assembles `kilo-cli.zip` as a backend resource.
+- Wire that generated resource only when `-Pkilo.cli.bundled=true` or local repo CLI mode is active.
+- Leave the production guard against `kilo.cli.pinned=false` intact.
+
+Bundled build command:
+
+```bash
+./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \
+ -Pproduction=true -Pkilo.version= -Pkilo.channel=default \
+ -Pkilo.cli.bundled=true
+```
+
+## Phase 3: Bundle Workflow
+
+- Add `.github/workflows/bundle-jetbrains.yml`.
+- Add a final success step to `publish-jetbrains.yml` that dispatches the bundle workflow with the merged release PR and merge commit.
+- The bundle workflow checks out the merged release PR for validation, then checks out the immutable `jetbrains/v` tag, restores reviewed release metadata, builds the bundled variant, signs it, verifies it, and uploads `kilo-code--bundled.zip` to the same GitHub Release.
+- Bundle ZIPs are produced for RC and stable releases. Only stable releases update the custom plugin repository XML.
+
+## Phase 4: GitHub Pages Repository
+
+- Generate `jetbrains/updatePlugins.xml` from the signed bundled ZIP metadata on stable releases.
+- Point the plugin URL at the uploaded GitHub Release asset.
+- Deploy the XML with GitHub Pages Actions to `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`.
+- Users add that URL in JetBrains IDEs under Settings -> Plugins -> Manage Plugin Repositories.
+
+## Acceptance Criteria
+
+- Marketplace builds remain unchanged and download the CLI at runtime.
+- Bundled builds use the same tag and source, keep `kilo.cli.pinned=true`, and differ only by `-Pkilo.cli.bundled=true`.
+- Bundled ZIPs are signed and attached to the `jetbrains/v` release.
+- Runtime extracts the bundled current-platform CLI and never downloads when `kilo-cli.zip` is present.
+- Stable releases update the GitHub Pages `updatePlugins.xml` with the latest bundled signed ZIP URL.
diff --git a/script/check-workflows.ts b/script/check-workflows.ts
index c429937bdc..46ec3530cf 100644
--- a/script/check-workflows.ts
+++ b/script/check-workflows.ts
@@ -29,6 +29,7 @@ const DIR = path.join(ROOT, ".github", "workflows")
const active = new Set([
"auto-docs.yml",
"beta.yml",
+ "bundle-jetbrains.yml",
"check-forbidden-strings.yml",
"check-kilo-generated-artifacts.yml",
"check-md-table-padding.yml",