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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-cli-mode-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---

Log whether the JetBrains plugin downloads Core or uses the bundled/cached version, and mark the Core version shown in the popup as "Bundled" when it wasn't downloaded.
92 changes: 92 additions & 0 deletions .kilo/skills/jetbrains-cli-pin/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
name: jetbrains-cli-pin
description: Use when pinning or unpinning the CLI version the Kilo JetBrains plugin uses, or fresh-regenerating the local repo CLI. Cleans all leftover CLI binaries and build artifacts in the current worktree so every operation starts from a fresh, artifact-free state.
---

# JetBrains CLI Pin

Pin the Kilo JetBrains plugin to the latest released CLI, unpin it to use the local
repo CLI, or fresh-regenerate the local CLI while unpinned. Every command first cleans
all CLI/pin build artifacts and binaries in the current worktree so the result never
carries state from a previous run.

Run all commands from the repository root of the worktree you want to affect. Paths are
relative, so they resolve to the current worktree, not the main checkout.

## Two Controls

The plugin's CLI behavior is governed by two independent values:

| Control | Location | Meaning |
|---|---|---|
| Pin mode | `packages/kilo-jetbrains/gradle.properties` -> `kilo.cli.pinned` | `true` = download the released CLI at build/connect time. `false` = build and bundle the local repo CLI. |
| Pinned version | `packages/kilo-jetbrains/package.json` -> `version` | Which GitHub CLI release is downloaded and generated from when `pinned=true`. |

"Pin to latest" means `kilo.cli.pinned=true` **and** `package.json` set to the latest
stable CLI release. "Unpin" means `kilo.cli.pinned=false` with a freshly built local CLI
bundled.

## Commands

```bash
bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts <command> [--no-verify]
```

| Command | Steps |
|---|---|
| `pin` | Clean -> set `kilo.cli.pinned=true` -> remove the repo-CLI Bun path hint -> bump `package.json` to latest release (via `set-pin.ts --latest`, which validates release assets) -> verify with a cold `gradlew clean typecheck`. |
| `unpin` | Clean -> set `kilo.cli.pinned=false` -> write the repo-CLI Bun path hint -> `:backend:buildRepoCli` (fresh CLI) -> `:backend:stageRepoCli` -> assert staged `kilo-cli.zip` -> verify with `gradlew typecheck`. |
| `regen` | Fast dev loop while unpinned: refresh the repo-CLI Bun path hint -> `rm -rf dist` -> `buildRepoCli` -> `stageRepoCli`. Refuses to run unless `kilo.cli.pinned=false`. |
| `clean` | Run the shared artifact clean only. |

`--no-verify` skips the gradle verification build (rewrites + clean only). Use it when
offline or without Java 21.

## Cleaned Artifacts

`clean()` runs `./gradlew clean` plus targeted deletes. All paths are gitignored, so
tracked files are never touched. The clean removes the stale artifacts that otherwise
leak across a pin/unpin flip:

| Artifact | Path |
|---|---|
| Repo CLI binaries | `packages/opencode/dist/` |
| Staged CLI archive | `packages/kilo-jetbrains/backend/build/generated/kilo-cli-res/kilo-cli.zip` |
| Generated props / checksums / OpenAPI client | `packages/kilo-jetbrains/backend/build/generated/` |
| Compiled resources (bundled zip on classpath) | `packages/kilo-jetbrains/backend/build/resources/` |
| CLI download cache | `packages/kilo-jetbrains/backend/build/cli-cache/` |

The staged `kilo-cli.zip` is the nastiest leak: once it lands in `backend/build/resources/main/`
from an unpinned build, runtime prefers the bundled zip over downloading. A full clean is
the only reliable reset.

## Bun Path Hint

In repo CLI mode, Gradle's `generateOpenApiSpec` task runs the local CLI source through
`bun run --conditions=browser ./src/index.ts generate`. IDE-launched Gradle runs can have
a stripped `PATH` where `bun` isn't resolvable. The `unpin`/`regen` commands write an ignored, worktree-local hint:

```text
packages/kilo-jetbrains/.gradle/kilo-cli-pin.properties
```

The file contains `bun.path=<absolute path>` and is consumed by `backend/build.gradle.kts`
for repo CLI tasks. `pin` removes it because pinned mode should not depend on local Bun.

## Notes

- Verification builds pass `--no-configuration-cache` so the changed `kilo.cli.pinned`
value is re-read instead of served from the on-disk Gradle configuration cache.
- The `pin` verification is a cold build: it downloads the pinned CLI release via
`generateOpenApiSpec` and needs network access plus Java 21. Use `--no-verify` offline.
- `kilo.cli.pinned=false` is dev-only and not releasable. Production Gradle builds,
`script/build-version.sh`, and the release scripts hard-fail on `false` -- run `pin`
before releasing.

## Related

- Version-bump and release-gating logic lives in the `release-jetbrains` skill
(`.kilo/skills/release-jetbrains/SKILL.md`); this skill reuses its `set-pin.ts` and
`pin-common.ts` helpers.
- Background on the build wiring: the "CLI Pinning, Unpinning, and Bumping" and "CLI
Integration" sections of `packages/kilo-jetbrains/AGENTS.md`.
23 changes: 23 additions & 0 deletions .kilo/skills/jetbrains-cli-pin/script/clean.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { $ } from "bun"

// Single source of truth for every CLI/pin artifact that can leak across a mode
// flip in the current worktree. Everything here is gitignored (dist, backend/build,
// .gradle), so cleaning never touches tracked files.
//
// The build's conditional sourceSets/dependsOn wiring in backend/build.gradle.kts only
// produces a correct package from a clean build/ directory. Incremental builds are what
// let a stale kilo-cli.zip survive a pin<->unpin flip, and runtime prefers a bundled
// zip over downloading -- so a full gradle clean is the reliable reset.
export async function clean(jb = "packages/kilo-jetbrains") {
// gradle clean wipes each project's build directory (including backend/build).
await $`./gradlew clean --quiet`.cwd(jb).nothrow()

// Stale per-platform CLI binaries. build.ts only rm -rf dist for the platforms it
// builds, so old platform dirs can survive; wipe the whole tree.
await $`rm -rf packages/opencode/dist`

// Belt-and-suspenders in case gradle clean was skipped or ran offline.
await $`rm -rf ${jb}/backend/build/generated`.nothrow()
await $`rm -rf ${jb}/backend/build/resources`.nothrow()
await $`rm -rf ${jb}/backend/build/cli-cache`.nothrow()
}
114 changes: 114 additions & 0 deletions .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env bun

import { $ } from "bun"
import { parseArgs } from "util"
import { clean } from "./clean"

const jb = "packages/kilo-jetbrains"
const props = `${jb}/gradle.properties`
const pkg = `${jb}/package.json`
const zip = `${jb}/backend/build/generated/kilo-cli-res/kilo-cli.zip`
const hint = `${jb}/.gradle/kilo-cli-pin.properties`

const arg = Bun.argv[2]
const cmd = arg && !arg.startsWith("-") ? arg : undefined
const { values } = parseArgs({
args: cmd ? Bun.argv.slice(3) : Bun.argv.slice(2),
options: {
"no-verify": { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
})

if (values.help || !cmd) {
console.log(`
Usage: bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts <command> [--no-verify]

Commands:
pin Pin the JetBrains plugin to the latest released CLI. Cleans artifacts,
sets kilo.cli.pinned=true, bumps package.json to the latest release,
then verifies with a cold gradle build (needs network + Java 21).
unpin Use the local repo CLI. Cleans artifacts, sets kilo.cli.pinned=false,
fresh-builds and stages the repo CLI, then verifies with typecheck.
regen Fast dev loop: rebuild + restage the local repo CLI (requires unpinned).
clean Remove all CLI/pin build artifacts and binaries in the current worktree.

Options:
--no-verify Skip the gradle verification build (rewrites + clean only).

Run from the repository root of the worktree you want to affect.
`)
process.exit(values.help ? 0 : 1)
}

async function pinned() {
const text = await Bun.file(props).text()
const line = text.split(/\r?\n/).find((l) => l.startsWith("kilo.cli.pinned="))
return (line?.split("=", 2)[1]?.trim().toLowerCase() ?? "true") === "true"
}

async function setPinned(value: boolean) {
const text = await Bun.file(props).text()
if (!/^kilo\.cli\.pinned=.*$/m.test(text)) throw new Error(`kilo.cli.pinned not found in ${props}`)
await Bun.write(props, text.replace(/^kilo\.cli\.pinned=.*$/m, `kilo.cli.pinned=${value}`))
}

function bunPath() {
return Bun.which("bun") ?? process.execPath
}

async function writeBunHint() {
const path = bunPath()
await $`mkdir -p ${jb}/.gradle`
await Bun.write(hint, `# Generated by jetbrains-cli-pin so IDE-launched Gradle can find Bun in repo CLI mode.\nbun.path=${path}\n`)
console.log(`Wrote Bun path hint for repo CLI mode: ${path}`)
}

async function removeBunHint() {
await $`rm -f ${hint}`.nothrow()
}

async function report() {
const version = (await Bun.file(pkg).json()).version
console.log(`\nState: kilo.cli.pinned=${await pinned()}, package.json version=${version}`)
}

if (cmd === "pin") {
await clean()
await setPinned(true)
await removeBunHint()
// set-pin.ts bumps package.json to the latest release and refuses versions with
// missing runtime assets, so we do not reimplement release/asset validation.
await $`bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest`
if (!values["no-verify"]) {
// Cold pinned build downloads the pinned CLI release via generateOpenApiSpec.
await $`./gradlew clean typecheck --no-configuration-cache`.cwd(jb)
}
await report()
} else if (cmd === "unpin") {
await clean()
await setPinned(false)
await writeBunHint()
// build.ts does rm -rf dist internally, producing a fresh single-platform binary.
await $`./gradlew :backend:buildRepoCli --no-configuration-cache`.cwd(jb)
// stageRepoCli has upToDateWhen{false}; force it so the staged zip matches this build.
await $`./gradlew :backend:stageRepoCli --no-configuration-cache`.cwd(jb)
if (!(await Bun.file(zip).exists())) throw new Error(`Expected staged CLI at ${zip} after unpin`)
if (!values["no-verify"]) {
await $`./gradlew typecheck --no-configuration-cache`.cwd(jb)
}
await report()
} else if (cmd === "regen") {
if (await pinned()) throw new Error("regen requires the unpinned state; run 'unpin' first")
await writeBunHint()
await $`rm -rf packages/opencode/dist`
await $`./gradlew :backend:buildRepoCli --no-configuration-cache`.cwd(jb)
await $`./gradlew :backend:stageRepoCli --no-configuration-cache`.cwd(jb)
if (!(await Bun.file(zip).exists())) throw new Error(`Expected staged CLI at ${zip} after regen`)
await report()
} else if (cmd === "clean") {
await clean()
await report()
} else {
throw new Error(`Unknown command '${cmd}'. Run with --help for usage.`)
}
2 changes: 2 additions & 0 deletions packages/kilo-jetbrains/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi

The JetBrains plugin has two independent CLI controls. Use the commands below directly when asked to change either one; do not hand-edit versions by guesswork.

For a one-shot pin/unpin/regen that also cleans every leftover CLI binary and build artifact in the current worktree, use the `jetbrains-cli-pin` skill (`.kilo/skills/jetbrains-cli-pin/SKILL.md`): `bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts <pin|unpin|regen|clean>`.

**Pin mode** (`kilo.cli.pinned` in `packages/kilo-jetbrains/gradle.properties`) controls release CLI vs local repo CLI.

| Ask | Do |
Expand Down
10 changes: 9 additions & 1 deletion packages/kilo-jetbrains/backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ val repoCli = pinned.map { !it }
val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false)
val downloadsCli = repoCli.zip(bundled) { repo, bundle -> !repo && !bundle }
val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode")
val local = rootProject.layout.projectDirectory.file(".gradle/kilo-cli-pin.properties")
val bunPathProvider = providers.fileContents(local).asText.map { text ->
text.lineSequence().firstNotNullOfOrNull { line ->
val pair = line.split("=", limit = 2)
if (pair.getOrNull(0)?.trim() == "bun.path") pair.getOrNull(1)?.trim()?.takeIf { it.isNotEmpty() } else null
} ?: "bun"
}.orElse("bun")

val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1)
Expand Down Expand Up @@ -64,12 +71,13 @@ val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) {
)
cacheDir.set(layout.buildDirectory.dir("cli-cache"))
spec.set(rawSpec)
bunPath.set(bunPathProvider)
}

val buildRepoCli by tasks.registering(Exec::class) {
description = "Build the local repo CLI for the current platform"
workingDir = repoRootDir.asFile
commandLine("bun", "run", "script/build.ts", "--single", "--skip-install")
commandLine(bunPathProvider.get(), "run", "script/build.ts", "--single", "--skip-install")
}

fun platform(): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,18 @@ class KiloBackendCliManager(
private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File {
val force = forceExtract
forceExtract = false
val version = KiloProps.cliVersion()
val platform = KiloCliPlatform.current()
if (KiloRepoCli.available()) {
if (force) log.info("Force re-extracting bundled CLI ${KiloProps.cliVersion()}")
if (force) log.info("Force re-extracting bundled CLI $version")
log.info("Kilo CLI mode: BUNDLED — using CLI $version ($platform) shipped in the plugin; no download needed")
val cli = KiloRepoCli.extract(force)
onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current()))
onProgress(CliDownload(100, version, platform))
return cli
}
if (force) log.info("Force re-downloading CLI ${KiloProps.cliVersion()}")
return KiloCliDownloader(log = log).resolve(KiloProps.cliVersion(), force, onProgress)
if (force) log.info("Force re-downloading CLI $version")
log.info("Kilo CLI mode: DOWNLOAD — resolving CLI $version ($platform) from the GitHub release")
return KiloCliDownloader(log = log).resolve(version, force, onProgress)
}

// Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ class KiloCliDownloader(
"completeExists=${done.isFile} digestValid=$valid exe=${exe.absolutePath} complete=${done.absolutePath}"
)
if (!exe.isFile || !valid) return null
log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}")
log.info("Kilo CLI $version ($platform) already cached at ${exe.absolutePath}; skipping download and extraction")
if (!SystemInfo.isWindows) exe.setExecutable(true)
prune(version)
return exe
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ object KiloRepoCli {
val exe = File(root, "$platform/bin/${KiloCliPlatform.exe()}")
val done = File(root, ".complete")
if (!force && done.isFile && exe.isFile) {
log.info("Bundled Kilo CLI ${KiloProps.cliVersion()} ($platform) already extracted at ${exe.absolutePath}; skipping extraction")
if (!SystemInfo.isWindows) exe.setExecutable(true)
if (cleanup) prune(root)
return@withContext exe
}
log.info("Extracting bundled Kilo CLI ${KiloProps.cliVersion()} ($platform) into ${root.absolutePath}")

if (root.exists() && !root.deleteRecursively()) {
throw IllegalStateException("Failed to delete local repo CLI under ${root.absolutePath}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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.backend.cli.KiloRepoCli
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.KiloAppRpcApi
Expand Down Expand Up @@ -57,6 +58,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {

override suspend fun cliPlatform(): String = KiloCliPlatform.current()

override suspend fun cliBundled(): Boolean = KiloRepoCli.available()

override suspend fun retry() = app.retry()

override suspend fun restart() = app.restart()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class KiloCliDownloaderTest {
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}")
assertContains(log.messages, "INFO: Kilo CLI 1.2.3 (${KiloCliPlatform.current()}) already cached at ${cli.absolutePath}; skipping download and extraction")

File(cli.parentFile.parentFile, ".complete").writeText("ok\n")
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
@get:Internal
abstract val cacheDir: DirectoryProperty

@get:Internal
abstract val bunPath: Property<String>

@get:OutputFile
abstract val spec: RegularFileProperty

Expand Down Expand Up @@ -76,7 +79,7 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
val err = ByteArrayOutputStream()
val result = exec.exec {
workingDir = root
commandLine("bun", "run", "--conditions=browser", "./src/index.ts", "generate")
commandLine(bunPath.get(), "run", "--conditions=browser", "./src/index.ts", "generate")
standardOutput = out
errorOutput = err
isIgnoreExitValue = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ class CoreInfoAction : AnAction(), DumbAware {
val app = service<KiloAppService>()
val info = app.core
if (info == null) app.fetchCoreInfoAsync()
app.fetchBundledAsync()
val key = if (app.bundled == true) "action.Kilo.CoreInfo.bundled" else "action.Kilo.CoreInfo.text"
e.presentation.text = info?.let {
KiloBundle.message("action.Kilo.CoreInfo.text", it.version, it.platform)
KiloBundle.message(key, it.version, it.platform)
} ?: KiloBundle.message("action.Kilo.CoreInfo.loading")
e.presentation.description = KiloBundle.message("action.Kilo.CoreInfo.description")
e.presentation.isEnabled = false
Expand Down
Loading
Loading