Merge pull request #12047 from Kilo-Org/dark-shield

feat(jetbrains): add repo CLI dev mode
This commit is contained in:
Kirill Kalishev
2026-07-09 08:50:57 -04:00
committed by GitHub
20 changed files with 727 additions and 27 deletions
+2
View File
@@ -35,7 +35,9 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inpu
permissions:
id-token: write
contents: write
issues: write # kilocode_change - label automated JetBrains CLI pin bump PRs
packages: write
pull-requests: write # kilocode_change - create automated JetBrains CLI pin bump PRs
jobs:
version:
+49 -17
View File
@@ -38,36 +38,67 @@ 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.
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. Prepare tags `origin/main`, so the authoritative pin is the value on `origin/main`, not a local edit.
Read the pinned CLI version:
Run the pin preflight:
```bash
bun -e 'const p=require("./packages/kilo-jetbrains/package.json"); console.log(p.version)'
bun .kilo/skills/release-jetbrains/script/check-pin.ts
```
Verify the matching GitHub Release exists and includes every runtime asset the backend may download:
The script prints:
| Field | Meaning |
|---|---|
| `pinMain` | CLI version that `origin/main` will lock into the release tag. |
| `pinLocal` | CLI version in the current worktree, useful for catching stale local checkouts. |
| `latestCli` | Latest stable `v*` Kilo CLI GitHub release. |
| `prevJetbrainsCli` | CLI pin used by the latest `jetbrains/v*` release tag, for reviewing the jump. |
| `pinnedMain` / `pinnedLocal` | Whether `kilo.cli.pinned=true`; `false` means repo CLI dev mode. |
| `assetsOk` / `missingAssets` | Whether the pinned CLI release has every runtime asset. |
| `drift` | `up-to-date`, `behind`, `worktree-behind-main`, `repo-mode-on-main`, `repo-mode-local`, or `assets-missing`. |
Interpretation:
| Drift | Action |
|---|---|
| `up-to-date` | Continue after user confirmation. |
| `behind` | Stop and show `pinMain`, `latestCli`, and `prevJetbrainsCli`; ask whether to cancel, bump + test, or proceed anyway. |
| `worktree-behind-main` | Explain that prepare tags `origin/main`; refresh the worktree or rely on `pinMain` in the confirmation. |
| `repo-mode-on-main` | Stop. `kilo.cli.pinned=false` is dev-only and must be reset to `true` on `main` before release. |
| `repo-mode-local` | Stop or reset local `kilo.cli.pinned=true`; release checks should run from a releasable local state. |
| `assets-missing` | Stop. The pinned CLI release is incomplete and would fail runtime download. |
Show the resolved JetBrains plugin version, release kind, default `fromTagDefault`, `pinMain`, `latestCli`, `prevJetbrainsCli`, and `assetsOk` to the user, then ask for confirmation before continuing. If the user wants a different CLI pin, use the bump workflow below and do not dispatch prepare until the bump is merged to `main`.
## Bump the CLI Pin
Use this only when the user wants to test or release with a different CLI than `origin/main` currently pins. The helper refuses versions whose GitHub release or runtime assets are missing.
Local test edit only:
```bash
cli_version="7.4.1"
gh release view "v${cli_version}" --repo Kilo-Org/kilocode --json assets \
--jq '.assets[].name' | sort
bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest
# or
bun .kilo/skills/release-jetbrains/script/set-pin.ts --version 7.4.1
```
Expected assets:
Then test from `packages/kilo-jetbrains/`:
```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
```bash
./gradlew typecheck
./gradlew test
```
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.
If the user confirms the tested pin should be released, open or update a pin bump PR to `main`:
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.
```bash
bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest --pr
# or
bun .kilo/skills/release-jetbrains/script/set-pin.ts --version 7.4.1 --pr
```
After that PR merges to `main`, re-run `resolve-version.ts`, re-run `check-pin.ts`, confirm `drift=up-to-date`, then dispatch prepare. Do not dispatch prepare from a local-only pin edit; the prepare workflow tags `origin/main`.
## Prepare Workflow
@@ -200,6 +231,7 @@ Report the Marketplace channel and GitHub Release URL. RC versions publish to th
- If prepare created the tag but failed before creating a PR, rerun prepare for the same version. The existing workflow reuses the tag if it points to the same commit.
- If a tag points to an unexpected SHA, stop and inspect manually. Do not move or delete release tags casually.
- If prepare tagged an unintended CLI pin, do not move the tag. Land the intended pin on `main`, resolve the next JetBrains version, and create a new release tag.
- If release PR checks fail from an apparent flake, use `gh run rerun <run-id> --failed`, then `gh run watch <run-id> --exit-status` before publishing.
- If publish fails after merge, rerun the failed workflow only if Marketplace did not already accept the version.
- If Marketplace succeeds but GitHub Release upload fails, manually create or edit the GitHub Release for `jetbrains/v<version>` using the reviewed changelog.
@@ -0,0 +1,89 @@
#!/usr/bin/env bun
import { $ } from "bun"
import semver from "semver"
import { parseArgs } from "util"
import { latest, missing } from "./pin-common"
const repo = process.env.GH_REPO ?? process.env.GITHUB_REPOSITORY ?? "Kilo-Org/kilocode"
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
help: { type: "boolean", short: "h", default: false },
},
})
if (values.help) {
console.log(`
Usage: bun .kilo/skills/release-jetbrains/script/check-pin.ts
Checks the CLI pin that a JetBrains release would lock. Prepare tags origin/main,
so this reads packages/kilo-jetbrains/package.json from origin/main and compares
it with the latest published Kilo CLI release plus the local worktree pin.
Exit codes:
0 Pin is release-ready.
2 Pin drift, repo CLI mode, or missing CLI assets require maintainer review.
`)
process.exit(0)
}
await $`git fetch origin main --tags`.quiet()
const pinMain = JSON.parse(await $`git show origin/main:packages/kilo-jetbrains/package.json`.text()).version as string
const pinLocal = (await Bun.file("packages/kilo-jetbrains/package.json").json()).version as string
const propsMain = await $`git show origin/main:packages/kilo-jetbrains/gradle.properties`.text()
const propsLocal = await Bun.file("packages/kilo-jetbrains/gradle.properties").text()
const pinnedMain = pinned(propsMain)
const pinnedLocal = pinned(propsLocal)
const latestCli = await latest(repo)
const prevJetbrainsCli = await previous()
const missingAssets = await missing(repo, pinMain)
const assetsOk = missingAssets.length === 0
const drift = (() => {
if (!pinnedMain) return "repo-mode-on-main"
if (!pinnedLocal) return "repo-mode-local"
if (pinLocal !== pinMain) return "worktree-behind-main"
if (!assetsOk) return "assets-missing"
if (latestCli && semver.lt(pinMain, latestCli)) return "behind"
return "up-to-date"
})()
console.log(JSON.stringify({
pinMain,
pinLocal,
latestCli,
prevJetbrainsCli,
pinnedMain,
pinnedLocal,
assetsOk,
missingAssets,
drift,
}, null, 2))
if (drift !== "up-to-date") process.exit(2)
async function previous() {
const text = await $`git tag --list ${"jetbrains/v*"}`.text()
const tag = text
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.map((tag) => ({ tag, version: tag.replace(/^jetbrains\/v/, "") }))
.filter((item) => semver.valid(item.version))
.sort((a, b) => semver.rcompare(a.version, b.version))[0]?.tag
if (!tag) return null
const res = await $`git show ${tag}:packages/kilo-jetbrains/package.json`.nothrow().text()
if (!res.trim()) return null
return JSON.parse(res).version as string
}
function pinned(text: string) {
const value = text.split(/\r?\n/).flatMap((line) => {
const [key, value] = line.split("=", 2)
if (key.trim() !== "kilo.cli.pinned") return []
return [value?.trim().toLowerCase()]
})[0]
return value == null || value === "true"
}
@@ -0,0 +1,30 @@
import { $ } from "bun"
import semver from "semver"
export const assets = [
"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",
]
export async function latest(repo: string) {
const list = (await $`gh release list --repo ${repo} --limit 100 --json tagName,isDraft,isPrerelease`.json()) as {
tagName: string
isDraft: boolean
isPrerelease: boolean
}[]
return list
.filter((item) => /^v\d+\.\d+\.\d+$/.test(item.tagName) && !item.isDraft && !item.isPrerelease)
.map((item) => item.tagName.slice(1))
.sort(semver.rcompare)[0] ?? null
}
export async function missing(repo: string, version: string) {
const res = await $`gh release view ${`v${version}`} --repo ${repo} --json assets --jq ${".assets[].name"}`.quiet().nothrow()
if (res.exitCode !== 0) return assets
const names = res.stdout.toString().split(/\r?\n/).map((item) => item.trim()).filter(Boolean)
return assets.filter((item) => !names.includes(item))
}
@@ -0,0 +1,123 @@
#!/usr/bin/env bun
import { $ } from "bun"
import semver from "semver"
import { parseArgs } from "util"
import { latest, missing } from "./pin-common"
const repo = process.env.GH_REPO ?? process.env.GITHUB_REPOSITORY ?? "Kilo-Org/kilocode"
const file = "packages/kilo-jetbrains/package.json"
const label = "jetbrains-cli-pin-bump"
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
version: { type: "string" },
latest: { type: "boolean", default: false },
pr: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
})
if (values.help) {
console.log(`
Usage: bun .kilo/skills/release-jetbrains/script/set-pin.ts (--latest | --version <x.y.z>) [--pr]
Without --pr, rewrites ${file} in the local worktree so you can test a CLI pin.
With --pr, opens or updates a PR against main using the GitHub API; prepare tags
origin/main, so the pin bump must merge there before a JetBrains release starts.
Examples:
bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest
bun .kilo/skills/release-jetbrains/script/set-pin.ts --version 7.4.1
bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest --pr
`)
process.exit(0)
}
if (values.latest && values.version) throw new Error("Pass either --latest or --version, not both")
const version = values.latest ? await latest(repo) : values.version?.replace(/^v/, "")
if (!version || !semver.valid(version) || semver.prerelease(version)) {
throw new Error("Pass a stable CLI version with --version x.y.z or use --latest")
}
const miss = await missing(repo, version)
if (miss.length > 0) {
throw new Error(`CLI release v${version} is missing required assets: ${miss.join(", ")}`)
}
if (values.pr) {
await pr(version)
process.exit(0)
}
const pkg = await Bun.file(file).json()
const previous = pkg.version as string
pkg.version = version
await Bun.write(file, `${JSON.stringify(pkg, null, 2)}\n`)
if (previous === version) {
console.log(`${file} already pins CLI v${version}`)
} else {
console.log(`Pinned JetBrains CLI ${previous} -> ${version} in ${file}`)
}
console.log("Test locally with: cd packages/kilo-jetbrains && ./gradlew typecheck && ./gradlew test")
console.log("When satisfied, run this script again with --pr so the bump lands on main before prepare tags it.")
async function pr(version: string) {
await $`git fetch origin main`.quiet()
const branch = `chore/jetbrains-cli-pin-v${version}`
const main = (await $`git rev-parse origin/main`.text()).trim()
const text = await $`git show origin/main:${file}`.text()
const pkg = JSON.parse(text)
const previous = pkg.version as string
if (previous === version) {
console.log(`origin/main already pins CLI v${version}; no PR needed.`)
return
}
pkg.version = version
const body = `${JSON.stringify(pkg, null, 2)}\n`
await ensure(branch, main)
const current = (await $`gh api ${`repos/${repo}/contents/${file}?ref=${branch}`}`.json()) as { sha: string }
await $`gh api --method PUT ${`repos/${repo}/contents/${file}`} -f message=${`chore(jetbrains): bump CLI pin to v${version}`} -f content=${Buffer.from(body).toString("base64")} -f branch=${branch} -f sha=${current.sha}`.quiet()
const title = `chore(jetbrains): bump CLI pin to v${version}`
const desc = [
`Bumps the JetBrains CLI pin from v${previous} to v${version}.`,
"",
"Prepare tags origin/main, so this PR must merge before dispatching a JetBrains release that should lock this CLI.",
"",
"After merging, re-run:",
"",
"```bash",
"bun .kilo/skills/release-jetbrains/script/check-pin.ts",
"```",
].join("\n")
const view = await $`gh pr view ${branch} --repo ${repo} --json url --jq .url`.quiet().nothrow()
if (view.exitCode === 0 && view.stdout.toString().trim()) {
await $`gh pr edit ${branch} --repo ${repo} --title ${title} --body ${desc}`
await tag(branch)
console.log(view.stdout.toString().trim())
return
}
const url = await $`gh pr create --repo ${repo} --base main --head ${branch} --title ${title} --body ${desc}`.text()
await tag(branch)
console.log(url.trim())
}
async function ensure(branch: string, sha: string) {
const ref = `repos/${repo}/git/refs/heads/${branch}`
const exists = await $`gh api ${ref}`.nothrow().quiet()
if (exists.exitCode === 0) {
await $`gh api --method PATCH ${ref} -f sha=${sha} -F force=true`.quiet()
return
}
await $`gh api --method POST ${`repos/${repo}/git/refs`} -f ref=${`refs/heads/${branch}`} -f sha=${sha}`.quiet()
}
async function tag(branch: string) {
await $`gh label create ${label} --repo ${repo} --color 1D76DB --description ${"JetBrains pinned CLI version bump"}`.quiet().nothrow()
const result = await $`gh pr edit ${branch} --repo ${repo} --add-label ${label}`.quiet().nothrow()
if (result.exitCode !== 0) {
console.warn(`Warning: failed to add ${label} label to ${branch}`)
}
}
+34 -4
View File
@@ -15,6 +15,8 @@
- `plugin.xml` `<content>` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`)
- Service classes ↔ `<applicationService>`/`<projectService>` entries in the corresponding module XML
- `packages/kilo-jetbrains/package.json` version ↔ GitHub CLI release tag consumed by the backend downloader
- `packages/kilo-jetbrains/gradle.properties` `kilo.cli.pinned` ↔ Gradle and release-script gates
- `.kilo/skills/release-jetbrains/script/check-pin.ts` / `set-pin.ts` ↔ release skill and CLI pin documentation
## IntelliJ Platform Source Lookup
@@ -154,8 +156,8 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
## CLI Integration
- 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.
- 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.
- 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")`.
- Detect OS with `com.intellij.openapi.util.SystemInfo.isMac` / `isLinux` / `isWindows`.
@@ -163,6 +165,33 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
- Resolve IDE paths with `com.intellij.openapi.application.PathManager` rather than inferring paths from process working directories.
- For packaging/build plumbing, see `script/build.ts` and `backend/build.gradle.kts`.
### CLI Pinning, Unpinning, and Bumping
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.
**Pin mode** (`kilo.cli.pinned` in `packages/kilo-jetbrains/gradle.properties`) controls release CLI vs local repo CLI.
| Ask | Do |
|---|---|
| Unpin / use local repo CLI | Set `kilo.cli.pinned=false`, then run `./gradlew :backend:buildRepoCli` from `packages/kilo-jetbrains/`. `:backend:stageRepoCli` bundles `packages/opencode/dist/@kilocode/cli-<os>-<arch>/bin/`; runtime extracts it instead of downloading. |
| Re-pin / use release CLI | Set `kilo.cli.pinned=true`. This is the default and the only releasable state. |
`kilo.cli.pinned=false` is dev-only: OpenAPI generation runs from local `packages/opencode/` source and the local binary is bundled. Production Gradle builds, `script/build-version.sh`, and the release scripts hard-fail on `false`, so restore `true` before releasing.
**Pinned CLI version** (`packages/kilo-jetbrains/package.json` `version`) controls which GitHub CLI release the plugin downloads and generates the client from. The JetBrains release locks the value already merged to `origin/main`.
| Ask | Do |
|---|---|
| Check whether the CLI pin is current | `bun .kilo/skills/release-jetbrains/script/check-pin.ts` |
| Bump the pin to `<version>` / latest and test locally | `bun .kilo/skills/release-jetbrains/script/set-pin.ts --version <x.y.z>` or `bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest`, then run `./gradlew typecheck && ./gradlew test` from `packages/kilo-jetbrains/`. |
| Land a tested pin bump for release | `bun .kilo/skills/release-jetbrains/script/set-pin.ts --version <x.y.z> --pr` or `bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest --pr`; merge the PR to `main`, then re-run `check-pin.ts` before dispatching prepare. |
`set-pin.ts` refuses versions whose CLI release or runtime assets do not exist, so it cannot create a pin that would 404 during runtime download.
Stable CLI releases also attempt this PR automatically after publishing and label it `jetbrains-cli-pin-bump`. The CLI release workflow logs the PR URL when creation succeeds and logs a warning without failing the release if PR creation fails.
For the full release process (resolve version, pin verification, prepare, changelog, publish), load the `release-jetbrains` skill: `.kilo/skills/release-jetbrains/SKILL.md`.
### Server Protocol
- The plugin spawns `kilo serve --port 0` (OS assigns random port) and reads stdout for `listening on http://...:(\d+)` to discover the port.
@@ -191,7 +220,8 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
- **Marketplace version build**: Use `script/build-version.sh <version>` 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 <version> --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. 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.
- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. A cold pinned build downloads the pinned CLI release via `generateOpenApiSpec` and needs network access; Gradle-cached incremental runs skip the download. Repo CLI mode (`-Pkilo.cli.pinned=false`) generates the spec from local source and bundles the staged local CLI binary.
- **Build local repo CLI for JetBrains dev**: `./gradlew :backend:buildRepoCli` from `packages/kilo-jetbrains/` builds `packages/opencode/dist/@kilocode/cli-<os>-<arch>/bin/`. `stageRepoCli` intentionally does not depend on this task; missing binaries fail with instructions instead of silently starting a slow CLI build.
- **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.
@@ -202,7 +232,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
### CLI/SDK Change Awareness
- 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.
- JetBrains runtime behavior normally depends on the downloaded CLI release pinned by `packages/kilo-jetbrains/package.json`; local `packages/opencode/` changes are used only with `kilo.cli.pinned=false` repo CLI mode.
- 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
+38
View File
@@ -10,8 +10,46 @@ Maintainers can use the Kilo `release-jetbrains` skill to drive this process fro
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<version>` release before creating the release tag.
`kilo.cli.pinned=false` in `packages/kilo-jetbrains/gradle.properties` is local development mode only. It generates the client from `packages/opencode/` and bundles a locally built CLI into the plugin; production Gradle builds and release scripts fail until the property is restored to `true`.
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.
## CLI Pin Review
The JetBrains plugin has two independent versions:
| Field | Meaning |
|---|---|
| `packages/kilo-jetbrains/package.json` `version` | The pinned Kilo CLI release used for OpenAPI generation and runtime downloads. |
| `packages/kilo-jetbrains/gradle.properties` `kilo.jetbrains.version` | The JetBrains Marketplace plugin version. |
The prepare workflow tags `origin/main`, so the CLI pin that matters is the one already merged to `main`. Before creating a release tag, run:
```bash
bun .kilo/skills/release-jetbrains/script/check-pin.ts
```
The script reports the CLI that `origin/main` will lock, the latest published stable CLI release, the CLI shipped by the latest `jetbrains/v*` tag, whether `kilo.cli.pinned=true`, and whether all runtime assets exist. Stop before tagging if the pin is behind the latest CLI and you want to test the newer CLI first.
To test a different CLI pin locally:
```bash
bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest
cd packages/kilo-jetbrains
./gradlew typecheck
./gradlew test
```
To land the tested pin on `main` before releasing:
```bash
bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest --pr
```
Merge the generated pin PR first, then re-run `check-pin.ts` and dispatch prepare. Do not dispatch prepare from a local-only pin edit.
Stable CLI releases also try to open this pin bump PR automatically after publishing. The PR is labeled `jetbrains-cli-pin-bump`. Release publishing does not fail if creating the PR fails; inspect the publish log for either the PR URL or the warning with manual follow-up instructions.
## Create Release Tag And PR
1. Open the GitHub Actions workflow:
@@ -1,4 +1,6 @@
import normalization.NormalizeOpenApiSpecTask
import org.gradle.api.GradleException
import org.gradle.api.tasks.Exec
import org.gradle.api.tasks.WriteProperties
plugins {
@@ -17,6 +19,10 @@ 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 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 repoRootDir = rootProject.layout.projectDirectory.dir("../opencode")
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1)
@@ -26,6 +32,7 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector
sourceSets {
main {
resources.srcDir(generatedProps)
if (repoCli.get()) resources.srcDir(generatedCli)
kotlin.srcDir(generatedApi)
}
}
@@ -35,11 +42,14 @@ val writeKiloProperties by tasks.registering(WriteProperties::class) {
val out = generatedProps.map { it.file("kilo.properties") }
destinationFile.set(out)
property("cli.version", pinnedCliVersion)
property("cli.pinned", pinned.map { it.toString() })
}
val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) {
description = "Generate CLI OpenAPI spec into the build directory"
cliVersion.set(pinnedCliVersion)
repo.set(repoCli)
repoRoot.set(repoRootDir)
token.set(
providers.environmentVariable("GH_TOKEN")
.orElse(providers.environmentVariable("GITHUB_TOKEN"))
@@ -48,6 +58,36 @@ val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) {
spec.set(rawSpec)
}
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")
}
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"
}
val stageRepoCli by tasks.registering(StageRepoCliTask::class) {
description = "Stage the local repo CLI into backend resources"
val bin = repoRootDir.dir("dist/@kilocode/cli-${platform()}/bin")
this.bin.set(bin)
archive.set(generatedCli.map { it.file("kilo-cli.zip") })
outputs.upToDateWhen { false }
}
val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) {
description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation"
dependsOn(generateOpenApiSpec)
@@ -102,11 +142,13 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
tasks.named("compileKotlin") {
dependsOn(fixGeneratedApi, writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
inputs.dir(generatedApi)
}
tasks.named("processResources") {
dependsOn(writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
}
tasks.named("compileTestKotlin") {
@@ -87,6 +87,12 @@ 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()}")
val cli = KiloRepoCli.extract(force)
onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current()))
return cli
}
if (force) log.info("Force re-downloading CLI ${KiloProps.cliVersion()}")
return KiloCliDownloader(log = log).resolve(KiloProps.cliVersion(), force, onProgress)
}
@@ -13,4 +13,8 @@ object KiloProps {
fun cliVersion(): String = props.getProperty("cli.version")
?: throw IllegalStateException("cli.version missing from kilo.properties")
fun pinned(): Boolean = pinned(props)
internal fun pinned(props: Properties): Boolean = props.getProperty("cli.pinned")?.toBoolean() ?: true
}
@@ -0,0 +1,70 @@
package ai.kilocode.backend.cli
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.util.SystemInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.ZipInputStream
object KiloRepoCli {
suspend fun extract(force: Boolean): File = extract(
force = force,
root = File(PathManager.getSystemPath(), "kilo/repo-cli"),
source = {
KiloRepoCli::class.java.classLoader.getResourceAsStream("kilo-cli.zip")
?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with kilo.cli.pinned=false")
},
)
internal suspend fun extract(force: Boolean, root: File, source: () -> InputStream): File = withContext(Dispatchers.IO) {
val exe = File(root, "bin/${KiloCliPlatform.exe()}")
val done = File(root, ".complete")
if (!force && done.isFile && exe.isFile) {
if (!SystemInfo.isWindows) exe.setExecutable(true)
return@withContext exe
}
if (root.exists() && !root.deleteRecursively()) {
throw IllegalStateException("Failed to delete local repo CLI under ${root.absolutePath}")
}
if (!root.isDirectory && !root.mkdirs()) {
throw IllegalStateException("Failed to create local repo CLI directory ${root.absolutePath}")
}
source().use { input ->
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
write(root, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
zip.closeEntry()
}
}
}
if (!exe.isFile) throw IllegalStateException("Local repo CLI archive did not contain bin/${KiloCliPlatform.exe()}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
done.writeText("ok\n")
return@withContext exe
}
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 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)
}
}
}
@@ -0,0 +1,69 @@
package ai.kilocode.backend.cli
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.Properties
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.assertTrue
class KiloRepoCliTest {
@TempDir
lateinit var dir: File
@Test
fun `extracts cached repo cli and force re-extracts`() = runBlocking {
val first = archive("#!/bin/old\n")
val next = archive("#!/bin/new\n")
val cli = KiloRepoCli.extract(false, dir) { ByteArrayInputStream(first) }
assertTrue(cli.isFile)
assertEquals("#!/bin/old\n", cli.readText())
assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile)
assertTrue(File(dir, ".complete").isFile)
val cached = KiloRepoCli.extract(false, dir) { ByteArrayInputStream(next) }
assertEquals(cli.absolutePath, cached.absolutePath)
assertEquals("#!/bin/old\n", cached.readText())
val forced = KiloRepoCli.extract(true, dir) { ByteArrayInputStream(next) }
assertEquals(cli.absolutePath, forced.absolutePath)
assertEquals("#!/bin/new\n", forced.readText())
}
@Test
fun `rejects archive entries that escape root`() = runBlocking {
val ex = assertFailsWith<IllegalStateException> {
KiloRepoCli.extract(false, dir) { ByteArrayInputStream(archive(entry = "../../../bad")) }
}
assertContains(ex.message.orEmpty(), "escapes target directory")
}
@Test
fun `pinned defaults true unless explicitly false`() {
assertEquals(true, KiloProps.pinned(Properties()))
assertEquals(true, KiloProps.pinned(Properties().apply { setProperty("cli.pinned", "true") }))
assertEquals(false, KiloProps.pinned(Properties().apply { setProperty("cli.pinned", "false") }))
}
private fun archive(script: String = "#!/bin/sh\n", entry: String = "bin/${KiloCliPlatform.exe()}"): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip ->
zip.putNextEntry(ZipEntry(entry))
zip.write(script.toByteArray())
zip.closeEntry()
zip.putNextEntry(ZipEntry("bin/kilo-sandbox-mutation-worker.js"))
zip.write("worker".toByteArray())
zip.closeEntry()
}
return out.toByteArray()
}
}
@@ -37,6 +37,12 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
@get:Input
abstract val cliVersion: Property<String>
@get:Input
abstract val repo: Property<Boolean>
@get:Internal
abstract val repoRoot: DirectoryProperty
@get:Internal
abstract val token: Property<String>
@@ -49,20 +55,51 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
@get:Inject
abstract val exec: ExecOperations
init {
repo.convention(false)
outputs.upToDateWhen { !repo.getOrElse(false) }
}
@TaskAction
fun run() {
if (repo.getOrElse(false)) {
generateFromRepo()
return
}
val kilo = resolve()
generate(kilo.absolutePath)
}
private fun generateFromRepo() {
val root = repoRoot.asFile.get()
val out = ByteArrayOutputStream()
val err = ByteArrayOutputStream()
val result = exec.exec {
commandLine(kilo.absolutePath, "generate")
workingDir = root
commandLine("bun", "run", "--conditions=browser", "./src/index.ts", "generate")
standardOutput = out
errorOutput = err
isIgnoreExitValue = true
}
if (result.exitValue != 0) {
writeSpec(result.exitValue, out, err)
}
private fun generate(kilo: String) {
val out = ByteArrayOutputStream()
val err = ByteArrayOutputStream()
val result = exec.exec {
commandLine(kilo, "generate")
standardOutput = out
errorOutput = err
isIgnoreExitValue = true
}
writeSpec(result.exitValue, out, err)
}
private fun writeSpec(code: Int, out: ByteArrayOutputStream, err: ByteArrayOutputStream) {
if (code != 0) {
throw GradleException(
"kilo generate failed with exit code ${result.exitValue}.\n" +
"kilo generate failed with exit code $code.\n" +
err.toString(Charsets.UTF_8).take(2000)
)
}
@@ -0,0 +1,45 @@
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.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
abstract class StageRepoCliTask : DefaultTask() {
@get:Internal
abstract val bin: DirectoryProperty
@get:OutputFile
abstract val archive: RegularFileProperty
@TaskAction
fun run() {
val dir = bin.asFile.get()
val exe = File(dir, exe())
if (!exe.isFile) {
throw GradleException(
"Repo CLI binary not found at ${exe.absolutePath}. Run ./gradlew :backend:buildRepoCli " +
"(or bun run script/build.ts --single --skip-install in packages/opencode) first."
)
}
val out = archive.get().asFile
out.parentFile.mkdirs()
ZipOutputStream(out.outputStream().buffered()).use { zip ->
dir.walkTopDown()
.filter { it.isFile }
.forEach { file ->
val name = "bin/${file.relativeTo(dir).invariantSeparatorsPath}"
zip.putNextEntry(ZipEntry(name))
file.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
}
}
}
private fun exe() = if (System.getProperty("os.name").lowercase().contains("windows")) "kilo.exe" else "kilo"
}
+5
View File
@@ -82,6 +82,7 @@ fun gitTag(): String? {
}
val release = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false).get()
val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true).get()
val override = providers.gradleProperty("kilo.version").orNull?.trim()?.takeIf { it.isNotEmpty() }
val prop = providers.gradleProperty("kilo.jetbrains.version").orNull?.trim()?.takeIf { it.isNotEmpty() }
val tag = gitTag()?.removePrefix("jetbrains/v")
@@ -89,6 +90,10 @@ val ver = override?.let(::checked) ?: prop?.let(::checked) ?: if (release) check
tag ?: error("Missing JetBrains plugin version. Publish builds must set kilo.jetbrains.version or run from a jetbrains/v<version> tag."),
) else checked(tag ?: "0.0.0-dev")
if (release && !pinned) error(
"kilo.cli.pinned=false is a dev-only mode and cannot be released. Set kilo.cli.pinned=true before a production/publish build."
)
val channel = providers.gradleProperty("kilo.channel").map { it.trim() }.orElse("default")
val splitPort = providers.gradleProperty("kilo.splitModeServerPort").map(::port).orElse(0)
val isolated = providers.gradleProperty("kilo.dev.storage.isolated").map { it.toBoolean() }.orElse(false)
@@ -1,5 +1,9 @@
kotlin.stdlib.default.dependency=false
kilo.jetbrains.version=7.0.2
# When true (default) the JetBrains plugin uses the pinned CLI release from package.json.
# Set to false ONLY for local dev: generate the client from local source + bundle the local binary.
# false is NOT releasable -- production builds fail unless this is true.
kilo.cli.pinned=true
org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m
@@ -86,6 +86,11 @@ if [[ ! -d "$plugin" ]]; then
exit 1
fi
if grep -Eq '^[[:space:]]*kilo\.cli\.pinned[[:space:]]*=[[:space:]]*false[[:space:]]*$' "$plugin/gradle.properties"; then
echo "kilo.cli.pinned=false is a dev-only mode and cannot be released. Set kilo.cli.pinned=true before building a version." >&2
exit 1
fi
if [[ "$sign" == "1" ]]; then
for file in "$chain" "$key" "$pass"; do
if [[ ! -s "$file" ]]; then
+14 -1
View File
@@ -42,6 +42,9 @@ if (kind === "stable" && !/^\d+\.\d+\.\d+$/.test(ver)) throw new Error("Stable v
if (!semver.valid(ver)) throw new Error(`Invalid semver: ${ver}`)
await $`git fetch origin main --tags`
if (!(await pinned())) {
throw new Error("packages/kilo-jetbrains/gradle.properties has kilo.cli.pinned=false; JetBrains releases require kilo.cli.pinned=true")
}
const tag = `jetbrains/v${ver}`
const branch = `jetbrains/release/v${ver}`
@@ -161,7 +164,7 @@ async function release(from: string, tag: string, sha: string) {
}
async function label(name: string, color: string, desc: string) {
const labels = (await $`gh label list --repo ${repo} --json name --limit 1000`.json()) as { name: string }[]
const labels: { name: string }[] = await $`gh label list --repo ${repo} --json name --limit 1000`.json()
if (labels.some((item) => item.name === name)) return
await $`gh label create ${name} --repo ${repo} --color ${color} --description ${desc}`
}
@@ -214,6 +217,16 @@ async function writeprops(ver: string) {
await Bun.write(props, next.endsWith("\n") ? next : `${next}\n`)
}
async function pinned() {
const text = await Bun.file(props).text()
const value = text.split(/\r?\n/).flatMap((line) => {
const [key, raw] = line.split("=", 2)
if (key.trim() !== "kilo.cli.pinned") return []
return [raw?.trim().toLowerCase()]
})[0]
return value == null || value === "true"
}
async function writelog(ver: string, entry: string) {
const current = await Bun.file(log)
.text()
+15 -2
View File
@@ -39,8 +39,8 @@ type Pull = {
state: string
}
const data =
(await $`gh pr view ${pr} --repo ${repo} --json body,headRefName,isCrossRepository,labels,mergedAt,mergeCommit,state`.json()) as Pull
const data: Pull =
await $`gh pr view ${pr} --repo ${repo} --json body,headRefName,isCrossRepository,labels,mergedAt,mergeCommit,state`.json()
const labels = new Set(data.labels.map((item) => item.name))
if (!labels.has("jetbrains-release")) throw new Error("PR is missing jetbrains-release label")
if (data.isCrossRepository) throw new Error("JetBrains release PR must come from this repository")
@@ -73,6 +73,9 @@ if (sha !== commit) throw new Error(`${tag} points at ${sha}, expected ${commit}
const prop = await props()
if (prop !== ver)
throw new Error(`packages/kilo-jetbrains/gradle.properties kilo.jetbrains.version is ${prop}, expected ${ver}`)
if (!(await pinned())) {
throw new Error("packages/kilo-jetbrains/gradle.properties has kilo.cli.pinned=false; JetBrains releases require kilo.cli.pinned=true")
}
const changelog = await Bun.file("packages/kilo-jetbrains/CHANGELOG.md").text()
if (!changelog.includes(`## [${ver}]`)) throw new Error(`CHANGELOG.md is missing section for ${ver}`)
@@ -117,3 +120,13 @@ async function props() {
if (!value) throw new Error("packages/kilo-jetbrains/gradle.properties is missing kilo.jetbrains.version")
return value
}
async function pinned() {
const text = await Bun.file("packages/kilo-jetbrains/gradle.properties").text()
const value = text.split(/\r?\n/).flatMap((line) => {
const [key, raw] = line.split("=", 2)
if (key.trim() !== "kilo.cli.pinned") return []
return [raw?.trim().toLowerCase()]
})[0]
return value == null || value === "true"
}
+43
View File
@@ -6,6 +6,11 @@ import { fileURLToPath } from "url"
console.log("=== publishing ===\n")
// kilocode_change start - keep JetBrains CLI pin reviewable outside CLI release commits
const jetbrainsPkg = fileURLToPath(new URL("../packages/kilo-jetbrains/package.json", import.meta.url))
const jetbrainsPin = await Bun.file(jetbrainsPkg).text()
// kilocode_change end
// kilocode_change start - consume changesets on the publish runner so changelog
// changes are included in the release commit. Previously this ran in the
// version job on a separate runner whose workspace was discarded.
@@ -42,6 +47,13 @@ const pkgjsons = await Array.fromAsync(
).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist")))
for (const file of pkgjsons) {
// kilocode_change start - create a follow-up PR for JetBrains CLI pin bumps
if (file === jetbrainsPkg) {
console.log("preserved JetBrains CLI pin:", file)
await Bun.file(file).write(jetbrainsPin)
continue
}
// kilocode_change end
let pkg = await Bun.file(file).text()
pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`)
console.log("updated:", file)
@@ -122,3 +134,34 @@ await import(`../packages/kilo-vscode/script/publish.ts`)
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
// kilocode_change start - non-blocking JetBrains CLI pin bump PR after stable CLI release
await createJetbrainsPinPr()
// kilocode_change end
// kilocode_change start
async function createJetbrainsPinPr() {
console.log("\n=== jetbrains cli pin bump pr ===\n")
if (!Script.release) {
console.log("Skipping JetBrains CLI pin bump PR: not a release build")
return
}
if (Script.preview) {
console.log(`Skipping JetBrains CLI pin bump PR for pre-release v${Script.version}`)
return
}
const result = await $`bun .kilo/skills/release-jetbrains/script/set-pin.ts --version ${Script.version} --pr`.nothrow()
const out = result.stdout.toString().trim()
const err = result.stderr.toString().trim()
if (result.exitCode === 0) {
if (out) console.log(out)
const url = out.match(/https:\/\/github\.com\/\S+\/pull\/\d+/)?.[0]
if (url) console.log(`::notice title=JetBrains CLI pin bump PR::${url}`)
return
}
console.warn("JetBrains CLI pin bump PR creation failed; release will continue.")
if (out) console.warn(out)
if (err) console.warn(err)
console.warn("::warning title=JetBrains CLI pin bump PR failed::Release completed, but the JetBrains CLI pin bump PR was not created. Check the logs above and create it manually if needed.")
}
// kilocode_change end