mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into fix/11709-custom-model-input-modalities
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix the reasoning-variant and mode dropdowns being clipped inside the New Worktree dialog. The dialog's scroll-container overflow escape now covers all inline selector popovers, not just the model picker, so dropdowns render fully above the prompt input.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show a clear "No changes found to generate a commit message for" error instead of a generic "Unexpected server error" when there is nothing to commit. The endpoint now returns a typed 422, and the extension surfaces the real message directly.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Harden config credential substitution against untrusted project config. Environment references (`{env:VAR}`) now resolve only in trusted config (global config, `KILO_CONFIG`, `KILO_CONFIG_CONTENT`, and org/MDM-managed config); a project-committed `kilo.json` / `opencode.json` can no longer use them. File references (`{file:...}`) still work in project config but are confined to the project root, so absolute paths, `../` traversal, and symlink escapes are rejected. This closes a path where a malicious repository could exfiltrate local secrets to an attacker-controlled `baseURL`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Support bidirectional text in the Agent Manager new worktree prompt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix multiline bidirectional prompt input rendering and file mention arrow navigation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix settings sidebar truncating localized section labels in non-English languages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Render images inline in the chat view when the agent reads an image file. Images appear below the read tool card and can be clicked to open a full-size preview.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Improve JetBrains session and code block padding alignment.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Reduce JetBrains plugin size by downloading the Kilo Core release on first connect.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Cap JetBrains reasoning and shell hover previews to a compact popup size.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Match the JetBrains progress text color to transcript text.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Hide the JetBrains prompt separator while the prompt is focused.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Balance JetBrains prompt input text padding.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Disable spellchecking in the JetBrains prompt input.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Remove old JetBrains CLI binaries so they no longer accumulate in the IDE cache. Only the active version is kept, the downloaded archive is deleted after extraction, and reinstalling re-downloads a fresh binary.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Auto-collapse JetBrains reasoning blocks when they finish streaming, keep manual expand/collapse choices, and preview collapsed reasoning on hover.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Use the session background across the JetBrains chat view from initial render.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Fix the JetBrains plugin finding shell-installed tools like bun and gh when launched from Finder or Dock.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Integrate project memory into the VS Code extension: memory status and controls in the Context settings tab, task-header and assistant-message affordances, the `/memory` prompt command, and Show/Toggle Project Memory command-palette entries.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Enable autocomplete across supported languages in Jupyter notebooks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Allow Shift+Arrow selections in the prompt input to shrink back across file mentions.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add a reload action that reboots the per-directory instance, picking up config, skills, agents, commands, and MCP prompts changed on disk. Sessions and history are preserved. Surfaces: `/reload` in the CLI palette and editor chat, a reload button in the task header and settings panel, the `Kilo Code: Reload Config and Skills` command, and a `POST /instance/reload` HTTP endpoint. The endpoint returns 409 while a session is actively running.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Show the Remote badge in the TUI prompt status area when remote session relay is enabled.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix the model usage panel showing just "free" for auto-routed sessions. The routed model id (e.g. `tencent/hy3:free`) is now displayed correctly instead of being collapsed to its `:free` suffix.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Widen the Additional Writable Paths input in the Sandboxing settings so longer filesystem paths are easier to read while typing.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-gateway": patch
|
||||
---
|
||||
|
||||
Use cloud account preferences to select the active Kilo organization and hide unavailable personal accounts.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Generate commit messages in the user's selected UI language instead of always using English.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Click or press Enter/Space on a bar in the task timeline to jump the transcript to that message.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve the selected mode when sending the first message in a new VS Code task so the chosen model is paired with the correct agent instructions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Wait for microphone capture to start before showing voice input as recording.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Show a dismissible notification when a leftover opencode config directory is found. Kilo no longer falls back to opencode configuration, so the notice points you to move `.opencode` config into a `.kilo` directory (or the global kilo config dir). Dismiss it once and it won't return unless the directory is still present.
|
||||
@@ -33,7 +33,10 @@ runs:
|
||||
shell: bash
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Restoring and extracting the ~1 GB cache took 2m23s on Windows, while a # kilocode_change
|
||||
# fresh install took 1m27s. Keep Windows off this cache until that reverses. # kilocode_change
|
||||
- name: Restore Bun dependencies
|
||||
if: runner.os != 'Windows' # kilocode_change
|
||||
id: bun-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
@@ -84,16 +87,17 @@ runs:
|
||||
|
||||
# Workaround for patched peer variants
|
||||
# e.g. ./patches/ for standard-openapi
|
||||
# https://github.com/oven-sh/bun/issues/28147
|
||||
# https://github.com/oven-sh/bun/issues/28147 # kilocode_change
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
bun install --linker hoisted ${{ inputs.install-flags }}
|
||||
bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }} # kilocode_change
|
||||
else
|
||||
bun install ${{ inputs.install-flags }}
|
||||
bun install --frozen-lockfile ${{ inputs.install-flags }} # kilocode_change
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
# Do not upload a Windows cache that Windows jobs intentionally never restore. # kilocode_change
|
||||
- name: Save Bun dependencies
|
||||
if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
|
||||
if: runner.os != 'Windows' && steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' # kilocode_change
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
|
||||
@@ -125,19 +125,11 @@ jobs:
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Prepare CLI resources
|
||||
working-directory: packages/kilo-jetbrains
|
||||
run: bun script/build.ts --production --prepare-cli
|
||||
env:
|
||||
KILO_VERSION: ${{ steps.release.outputs.version }}
|
||||
KILO_CHANNEL: ${{ steps.release.outputs.cli_channel }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
- name: Verify plugin
|
||||
working-directory: packages/kilo-jetbrains
|
||||
run: ./gradlew verifyPlugin -Pproduction=true -Pkilo.channel="$CHANNEL"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
CHANNEL: ${{ steps.release.outputs.marketplace_channel }}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
if: matrix.settings.run
|
||||
uses: actions/cache@v5 # kilocode_change
|
||||
with:
|
||||
path: node_modules/.cache/turbo
|
||||
path: .turbo/cache # kilocode_change
|
||||
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
|
||||
|
||||
@@ -34,7 +34,40 @@ Accepted specs:
|
||||
| `x.y.z-rc.n` | Explicit RC release. |
|
||||
| `x.y.z` | Explicit stable release. |
|
||||
|
||||
Show the resolved `version`, `kind`, and default `fromTagDefault` to the user and ask for confirmation before continuing.
|
||||
Show the resolved `version`, `kind`, and default `fromTagDefault` to the user.
|
||||
|
||||
## CLI Pin Verification
|
||||
|
||||
Before dispatching prepare, verify the JetBrains plugin is pinned to the intended Kilo Core release. The plugin downloads the CLI version from `packages/kilo-jetbrains/package.json`, not from the JetBrains plugin version.
|
||||
|
||||
Read the pinned CLI version:
|
||||
|
||||
```bash
|
||||
bun -e 'const p=require("./packages/kilo-jetbrains/package.json"); console.log(p.version)'
|
||||
```
|
||||
|
||||
Verify the matching GitHub Release exists and includes every runtime asset the backend may download:
|
||||
|
||||
```bash
|
||||
cli_version="7.4.1"
|
||||
gh release view "v${cli_version}" --repo Kilo-Org/kilocode --json assets \
|
||||
--jq '.assets[].name' | sort
|
||||
```
|
||||
|
||||
Expected assets:
|
||||
|
||||
```text
|
||||
kilo-darwin-arm64.zip
|
||||
kilo-darwin-x64.zip
|
||||
kilo-linux-arm64.tar.gz
|
||||
kilo-linux-x64.tar.gz
|
||||
kilo-windows-arm64.zip
|
||||
kilo-windows-x64.zip
|
||||
```
|
||||
|
||||
If the pin is stale or the release assets are missing, stop and ask the user to update `packages/kilo-jetbrains/package.json` on `main` before dispatching prepare. The prepare workflow tags `origin/main`, so the pin must already be reviewed and merged before the release tag is created.
|
||||
|
||||
Show the resolved JetBrains plugin version, release kind, default `fromTagDefault`, pinned CLI version, and CLI release asset status to the user, then ask for confirmation before continuing.
|
||||
|
||||
## Prepare Workflow
|
||||
|
||||
|
||||
+2
-2
@@ -126,10 +126,10 @@ Requires Java 21 (see [Prerequisites](#prerequisites)). From `packages/kilo-jetb
|
||||
```bash
|
||||
./gradlew typecheck # Compile-check all Kotlin sources
|
||||
./gradlew test # Run all tests (backend + frontend)
|
||||
./gradlew --no-configuration-cache runIdeSplitMode # Launch local split-mode sandbox and prepare the CLI
|
||||
./gradlew --no-configuration-cache runIdeSplitMode # Launch local split-mode sandbox; backend downloads the pinned CLI
|
||||
```
|
||||
|
||||
Use `./gradlew runIde` only for a monolithic sandbox; prepare the CLI first with `bun run build --prepare-cli`.
|
||||
Use `./gradlew runIde` only for a monolithic sandbox. JetBrains dev runs do not build or bundle CLI binaries; the backend downloads the pinned release at connect time.
|
||||
|
||||
Or via the root turbo filter to run only JetBrains checks from the repo root:
|
||||
|
||||
|
||||
@@ -338,6 +338,7 @@
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-indexing": "workspace:*",
|
||||
"@kilocode/kilo-memory": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-jTNTUFR9PuOqU3HVmA9tWR6E07O+LgQyubZOvQOxZ5I=",
|
||||
"aarch64-linux": "sha256-afSECQ+kAsWirSpUAPe8sVcsal4Cu37MBWG/klc5I4Q=",
|
||||
"aarch64-darwin": "sha256-z2LNeKVnKu95c6W2vl1x0qHTmZK9nwJlneurDibLjYw=",
|
||||
"x86_64-darwin": "sha256-9dRFffouzJNfewnMm9Hm0FgIbYeUMBgv9mJUEwlSZS0="
|
||||
"x86_64-linux": "sha256-5/NgXEBhaNEEqIz9GVCXxQaLJwkqWXAAZOPoa6GyHTI=",
|
||||
"aarch64-linux": "sha256-qt7PKn0SAhDCcYtXRY9+1TBp9x3Far7kK24jhUzoMf8=",
|
||||
"aarch64-darwin": "sha256-3FN/i3chx01S52lAv0tjxVtRpUlPkIfInphpq+69uWA=",
|
||||
"x86_64-darwin": "sha256-+rOacJLx+qOCRGW4F+AjvFcc9XUE2biB0BRSy3dTWGI="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ Use the Markdoc codicon tag format:
|
||||
|
||||
## Documentation Guidelines
|
||||
|
||||
### Style Guide
|
||||
|
||||
Before writing documentation, review `packages/kilo-docs/STYLE_GUIDE.md` for voice, tone, and formatting conventions.
|
||||
|
||||
### Adding New Pages
|
||||
|
||||
1. Create your page in the appropriate directory under `pages/`
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: "Documentation Style Guide"
|
||||
description: "Guidelines for writing Kilo Code documentation"
|
||||
---
|
||||
|
||||
# Documentation Style Guide
|
||||
|
||||
This guide covers writing, formatting, and structuring documentation for the Kilo Code docs site.
|
||||
|
||||
## Voice and Tone
|
||||
|
||||
Kilo Code documentation should be:
|
||||
|
||||
- **Clear and direct** - Cut unnecessary words. Prefer active voice.
|
||||
- **Helpful, not salesy** - Focus on what users can do, not just what's possible.
|
||||
- **Consistent** - Use the same terminology and phrasing across pages.
|
||||
- **Friendly but professional** - Write as a knowledgeable teammate explaining concepts.
|
||||
|
||||
### Do
|
||||
|
||||
- Write in the second person ("you")
|
||||
- Use present tense
|
||||
- Be specific: "Run `kilo run` to execute a task" not "You can run kilo run"
|
||||
|
||||
### Don't
|
||||
|
||||
- Use marketing fluff or hype language
|
||||
- Write in passive voice when active is clearer
|
||||
- Assume prior knowledge not explicitly stated
|
||||
|
||||
## Headings
|
||||
|
||||
- Use sentence case for heading text
|
||||
- Start with the most important word
|
||||
- One heading per section
|
||||
- Use heading levels logically (don't skip from H2 to H4)
|
||||
|
||||
```markdown
|
||||
## Installing Kilo Code
|
||||
|
||||
### VS Code Extension
|
||||
|
||||
### CLI
|
||||
```
|
||||
|
||||
## Procedures
|
||||
|
||||
Use numbered lists for step-by-step instructions. Each step should be a complete action.
|
||||
|
||||
```markdown
|
||||
1. Open VS Code
|
||||
2. Go to Extensions (Ctrl+Shift+X / Cmd+Shift+X)
|
||||
3. Search for "Kilo Code"
|
||||
4. Click the dropdown arrow next to **Install** and select **Install Pre-Release Version**
|
||||
```
|
||||
|
||||
### Procedural tips
|
||||
|
||||
- Include keyboard shortcuts in parentheses
|
||||
- Use present tense
|
||||
- Start each step with a verb
|
||||
- Don't number sub-steps; use nested lists instead
|
||||
|
||||
## Callouts
|
||||
|
||||
Use callouts to highlight important information. Choose the right type:
|
||||
|
||||
| Type | Use for |
|
||||
|---|---|
|
||||
| `note` | General information users should know |
|
||||
| `tip` | Helpful shortcuts or best practices |
|
||||
| `info` | Context or background information |
|
||||
| `warning` | Potential problems or important cautions |
|
||||
| `danger` | Critical warnings that could cause data loss |
|
||||
| `generic` | Content without a specific visual treatment |
|
||||
|
||||
```markdown
|
||||
{% callout type="tip" %}
|
||||
**The easiest way to configure Kilo is to ask the agent.** Just tell the agent what you want.
|
||||
{% /callout %}
|
||||
```
|
||||
|
||||
## Cross-References
|
||||
|
||||
- Use absolute paths starting from `/docs/` for internal links
|
||||
- Don't include `.md` extensions
|
||||
- Use descriptive link text, not "click here"
|
||||
|
||||
```markdown
|
||||
Good: [Quickstart Guide](/docs/getting-started/quickstart)
|
||||
|
||||
Bad: [Click here](/docs/getting-started/quickstart)
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
- Use fenced code blocks with language specified
|
||||
- Include comments in code where helpful
|
||||
- Show realistic, working examples
|
||||
- Use `kilo run` for CLI examples, not hypothetical commands
|
||||
|
||||
````markdown
|
||||
```bash
|
||||
kilo run "create a utils.py file with a function that adds two numbers"
|
||||
```
|
||||
````
|
||||
|
||||
### Code in prose
|
||||
|
||||
Use backticks for inline code, file references, and commands:
|
||||
|
||||
- `kilo.jsonc` for configuration files
|
||||
- `Ctrl+Shift+X` for keyboard shortcuts
|
||||
- `src/utils.ts` for file paths
|
||||
|
||||
## Markdoc Conventions
|
||||
|
||||
### Images
|
||||
|
||||
Use the Markdoc image tag format:
|
||||
|
||||
```markdown
|
||||
{% image src="/docs/img/kilo-provider/connected-accounts.png" alt="Connect account screen" width="800" caption="Connect account screen" /%}
|
||||
```
|
||||
|
||||
**Image path rules:**
|
||||
- Always include `/docs` prefix
|
||||
- Use generated screenshots from `packages/kilo-docs/public/img/screenshot-tests/` when available
|
||||
- Write descriptive alt text for accessibility
|
||||
|
||||
### Tables
|
||||
|
||||
Use compact markdown tables without padding:
|
||||
|
||||
```markdown
|
||||
| Command | What it runs |
|
||||
|---|---|
|
||||
| `kilo serve` | The prod CLI on `$PATH`. |
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
Use tabs for platform-specific content:
|
||||
|
||||
```markdown
|
||||
{% tabs %}
|
||||
{% tab label="VS Code" %}
|
||||
|
||||
Content for VS Code
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
|
||||
Content for CLI
|
||||
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
```
|
||||
|
||||
### Mermaid Diagrams
|
||||
|
||||
Use fenced `mermaid` blocks for architecture diagrams:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A --> B
|
||||
```
|
||||
````
|
||||
|
||||
## LLM-Generated Docs
|
||||
|
||||
This documentation site is maintained with AI assistance. When reviewing or editing:
|
||||
|
||||
- Verify technical accuracy manually
|
||||
- Ensure examples actually work
|
||||
- Check that terminology is consistent
|
||||
- Don't accept generated content without review
|
||||
|
||||
## Terminology
|
||||
|
||||
Use consistent terms throughout:
|
||||
|
||||
| Term | Use for |
|
||||
|---|---|
|
||||
| Kilo Code | The product name |
|
||||
| kilo CLI | The command-line interface |
|
||||
| VS Code extension | The VS Code extension |
|
||||
| JetBrains plugin | The JetBrains IDE plugin |
|
||||
| `kilo serve` | The local HTTP server |
|
||||
| `kilo run` | The headless execution command |
|
||||
| agent | The AI assistant |
|
||||
|
||||
## Navigation
|
||||
|
||||
- Add new pages to the appropriate nav file in `lib/nav/`
|
||||
- Update `lib/nav/index.ts` to export the new nav section
|
||||
- Navigation files are organized by section (e.g., `getting-started.ts`, `code-with-ai.ts`)
|
||||
|
||||
## Documentation Lifecycle
|
||||
|
||||
- Follow the branch naming convention: `docs/description-of-change`
|
||||
- For documentation-only changes, create branches with the `docs/` prefix
|
||||
- Update navigation when adding or removing pages
|
||||
- Add redirects in `previous-docs-redirects.js` when moving or removing pages
|
||||
@@ -10,7 +10,6 @@ export const AutomateNav: NavSection[] = [
|
||||
href: "/automate/code-reviews/overview",
|
||||
children: "Code Reviews",
|
||||
subLinks: [
|
||||
{ href: "/automate/code-reviews/overview", children: "Overview" },
|
||||
{ href: "/automate/code-reviews/github", children: "GitHub" },
|
||||
{ href: "/automate/code-reviews/gitlab", children: "GitLab" },
|
||||
],
|
||||
@@ -19,7 +18,6 @@ export const AutomateNav: NavSection[] = [
|
||||
href: "/automate/agent-manager",
|
||||
children: "Agent Manager",
|
||||
subLinks: [
|
||||
{ href: "/automate/agent-manager", children: "Reference" },
|
||||
{ href: "/automate/agent-manager-workflows", children: "Workflows" },
|
||||
],
|
||||
},
|
||||
@@ -42,7 +40,6 @@ export const AutomateNav: NavSection[] = [
|
||||
href: "/automate/mcp/overview",
|
||||
children: "MCP",
|
||||
subLinks: [
|
||||
{ href: "/automate/mcp/overview", children: "MCP Overview" },
|
||||
{
|
||||
href: "/automate/mcp/using-in-kilo-code",
|
||||
children: "Using MCP in Kilo Code",
|
||||
|
||||
@@ -30,6 +30,11 @@ export const CodeWithAiNav: NavSection[] = [
|
||||
},
|
||||
{ href: "/code-with-ai/platforms/cloud-agent", children: "Cloud Agent" },
|
||||
{ href: "/code-with-ai/platforms/mobile", children: "Mobile Apps" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Features",
|
||||
links: [
|
||||
{ href: "/code-with-ai/app-builder", children: "App Builder" },
|
||||
{
|
||||
href: "/code-with-ai/gastown",
|
||||
@@ -80,7 +85,6 @@ export const CodeWithAiNav: NavSection[] = [
|
||||
href: "/code-with-ai/agents/using-agents",
|
||||
children: "Agents",
|
||||
subLinks: [
|
||||
{ href: "/code-with-ai/agents/using-agents", children: "Using Agents" },
|
||||
{
|
||||
href: "/code-with-ai/agents/orchestrator-mode",
|
||||
children: "Orchestrator Mode",
|
||||
@@ -89,7 +93,6 @@ export const CodeWithAiNav: NavSection[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
title: "Productivity Tools",
|
||||
links: [
|
||||
@@ -113,11 +116,7 @@ export const CodeWithAiNav: NavSection[] = [
|
||||
href: "/code-with-ai/features/browser-use",
|
||||
children: "Agent Behavior",
|
||||
subLinks: [
|
||||
{ href: "/code-with-ai/features/browser-use", children: "Browser Use" },
|
||||
{
|
||||
href: "/code-with-ai/features/task-todo-list",
|
||||
children: "Task Todo List",
|
||||
},
|
||||
{ href: "/code-with-ai/features/task-todo-list", children: "Task Todo List" },
|
||||
{ href: "/code-with-ai/features/checkpoints", children: "Checkpoints" },
|
||||
{ href: "/code-with-ai/features/file-encoding", children: "File Encoding" },
|
||||
],
|
||||
|
||||
@@ -27,27 +27,26 @@ export const CollaborateNav: NavSection[] = [
|
||||
},
|
||||
{ href: "/collaborate/teams/billing", children: "Billing" },
|
||||
{ href: "/collaborate/teams/analytics", children: "Analytics" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "AI Adoption Dashboard",
|
||||
links: [
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/overview",
|
||||
children: "AI Adoption Dashboard",
|
||||
subLinks: [
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/overview",
|
||||
children: "Overview",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/understanding-your-score",
|
||||
children: "Understanding Your Score",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/improving-your-score",
|
||||
children: "Improving Your Score",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/for-team-leads",
|
||||
children: "For Team Leads",
|
||||
},
|
||||
],
|
||||
children: "Overview",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/understanding-your-score",
|
||||
children: "Understanding Your Score",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/improving-your-score",
|
||||
children: "Improving Your Score",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/for-team-leads",
|
||||
children: "For Team Leads",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,28 +2,22 @@ import { NavSection } from "../types"
|
||||
|
||||
export const GettingStartedNav: NavSection[] = [
|
||||
{
|
||||
title: "Introduction",
|
||||
title: "Get Started",
|
||||
links: [
|
||||
{ href: "/getting-started", children: "Overview" },
|
||||
{ href: "/getting-started/installing", children: "Installation" },
|
||||
{ href: "/getting-started/quickstart", children: "Quickstart" },
|
||||
{ href: "/getting-started/setup-authentication", children: "Authentication" },
|
||||
{ href: "/getting-started/quickstart", children: "Your First Task" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
links: [
|
||||
{
|
||||
href: "/getting-started/setup-authentication",
|
||||
children: "Setup & Authentication",
|
||||
},
|
||||
{
|
||||
href: "/getting-started/using-kilo-for-free",
|
||||
children: "Using Kilo for Free",
|
||||
},
|
||||
{
|
||||
href: "/getting-started/byok",
|
||||
children: "Bring Your Own Key (BYOK)",
|
||||
},
|
||||
{ href: "/getting-started/byok", children: "Bring Your Own Key (BYOK)" },
|
||||
{ href: "/ai-providers", children: "AI Providers" },
|
||||
{
|
||||
href: "/getting-started/settings",
|
||||
@@ -34,7 +28,10 @@ export const GettingStartedNav: NavSection[] = [
|
||||
],
|
||||
},
|
||||
{ href: "/getting-started/adding-credits", children: "Adding Credits" },
|
||||
{ href: "/getting-started/rate-limits-and-costs", children: "Cost Efficiency & Model Selection" },
|
||||
{
|
||||
href: "/getting-started/rate-limits-and-costs",
|
||||
children: "Cost Efficiency & Model Selection",
|
||||
},
|
||||
{ href: "/getting-started/cost-controls-and-usage-safeguards", children: "Cost Controls and Usage Safeguards" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -12,7 +12,6 @@ export const KiloClawNav: NavSection[] = [
|
||||
href: "/kiloclaw/control-ui/overview",
|
||||
children: "Control UI",
|
||||
subLinks: [
|
||||
{ href: "/kiloclaw/control-ui/overview", children: "Overview" },
|
||||
{ href: "/kiloclaw/control-ui/changing-models", children: "Changing Models" },
|
||||
{ href: "/kiloclaw/control-ui/exec-approvals", children: "Exec Approvals" },
|
||||
{ href: "/kiloclaw/control-ui/version-pinning", children: "Version Pinning" },
|
||||
@@ -22,7 +21,6 @@ export const KiloClawNav: NavSection[] = [
|
||||
href: "/kiloclaw/chat-platforms",
|
||||
children: "Chat Platforms",
|
||||
subLinks: [
|
||||
{ href: "/kiloclaw/chat-platforms", children: "Overview" },
|
||||
{ href: "/kiloclaw/chat-platforms/telegram", children: "Telegram" },
|
||||
{ href: "/kiloclaw/chat-platforms/discord", children: "Discord" },
|
||||
{ href: "/kiloclaw/chat-platforms/slack", children: "Slack" },
|
||||
@@ -32,7 +30,6 @@ export const KiloClawNav: NavSection[] = [
|
||||
href: "/kiloclaw/development-tools",
|
||||
children: "Integrations",
|
||||
subLinks: [
|
||||
{ href: "/kiloclaw/development-tools", children: "Overview" },
|
||||
{ href: "/kiloclaw/development-tools/github", children: "GitHub" },
|
||||
{ href: "/kiloclaw/development-tools/google", children: "Google Workspace" },
|
||||
{ href: "/kiloclaw/development-tools/linear", children: "Linear" },
|
||||
@@ -47,7 +44,6 @@ export const KiloClawNav: NavSection[] = [
|
||||
href: "/kiloclaw/triggers",
|
||||
children: "Triggers",
|
||||
subLinks: [
|
||||
{ href: "/kiloclaw/triggers", children: "Overview" },
|
||||
{ href: "/kiloclaw/triggers/webhooks", children: "Webhooks" },
|
||||
{ href: "/kiloclaw/triggers/scheduled", children: "Scheduled" },
|
||||
],
|
||||
|
||||
@@ -39,7 +39,7 @@ The panel opens as an editor tab and stays active across focus changes.
|
||||
|
||||
Agent Manager uses the same sign-in, provider settings, models, BYOK keys, custom providers, MCP servers, and permission rules as the extension sidebar. Configure them from extension Settings and they apply to Agent Manager as well.
|
||||
|
||||
See [Setup & Authentication](/docs/getting-started/setup-authentication), [AI Providers](/docs/ai-providers), and [Bring Your Own Key](/docs/getting-started/byok) for setup details.
|
||||
See [Authentication](/docs/getting-started/setup-authentication), [AI Providers](/docs/ai-providers), and [Bring Your Own Key](/docs/getting-started/byok) for setup details.
|
||||
|
||||
## Working with Worktrees
|
||||
|
||||
|
||||
@@ -385,11 +385,15 @@ You can also set options that apply to all models from a provider:
|
||||
|
||||
| Option | Type | Description |
|
||||
|---|---|---|
|
||||
| `apiKey` | `string` | API key (supports `{env:VAR}` syntax) |
|
||||
| `apiKey` | `string` | API key (supports `{env:VAR}` and `{file:...}` syntax in trusted config — see note below) |
|
||||
| `baseURL` | `string` | Override the provider's base API URL |
|
||||
| `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable |
|
||||
| `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. |
|
||||
|
||||
{% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %}
|
||||
`{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config.
|
||||
{% /callout %}
|
||||
|
||||
## Filtering Available Models
|
||||
|
||||
Control which models appear in the model picker for a provider using allowlists and blocklists:
|
||||
|
||||
@@ -5,6 +5,10 @@ description: "Build complete applications with Kilo Code"
|
||||
|
||||
# App Builder
|
||||
|
||||
{% callout type="warning" title="App Builder is deprecated" %}
|
||||
App Builder is deprecated and no longer available to new users. Only existing users who previously used App Builder can still access it.
|
||||
{% /callout %}
|
||||
|
||||
Kilo's **App Builder** lets you create end-to-end applications through natural language conversation. Describe what you want to build, watch it come to life in a real-time preview, and deploy directly from your Kilo dashboard. No local environment setup required.
|
||||
|
||||
---
|
||||
|
||||
@@ -117,6 +117,7 @@ For detailed help on every command and subcommand, see the [CLI Command Referenc
|
||||
| `/status` | - | View status |
|
||||
| `/themes` | - | Switch theme |
|
||||
| `/help` | - | Show help |
|
||||
| `/reload` | - | Reload config, skills, agents, and commands from disk |
|
||||
| `/editor` | - | Open external editor |
|
||||
| `/exit` | `/quit`, `/q` | Exit the app |
|
||||
|
||||
@@ -338,6 +339,10 @@ The Kilo CLI is a fork of [OpenCode](https://opencode.ai) and supports the same
|
||||
|
||||
Project-level configuration takes precedence over global settings.
|
||||
|
||||
{% callout type="warning" %}
|
||||
**Migrating from opencode?** Kilo no longer falls back to opencode configuration stored in `.opencode` directories (such as `~/.config/opencode` or a project `./.opencode/`). To keep using it, move your global config into `~/.config/kilo/` and any project config into `./.kilo/`.
|
||||
{% /callout %}
|
||||
|
||||
### Key Configuration Options
|
||||
|
||||
```json
|
||||
@@ -464,6 +469,10 @@ Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variab
|
||||
}
|
||||
```
|
||||
|
||||
{% callout type="warning" title="Only works in trusted config" %}
|
||||
`{env:VAR}` (and `{file:...}`) references are resolved **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}` — the reference is ignored and a warning is logged. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected.
|
||||
{% /callout %}
|
||||
|
||||
For full details on all configuration options including compaction, file watchers, plugins, and experimental features, see the [OpenCode Config documentation](https://opencode.ai/docs/config).
|
||||
|
||||
## Interactive Mode
|
||||
|
||||
@@ -131,10 +131,10 @@ JetBrains Kotlin toolchain is Java 21. Gradle commands report missing or incompa
|
||||
| Typecheck | `./gradlew typecheck` |
|
||||
| Tests | `./gradlew test` |
|
||||
| Full plugin build | `bun run build` |
|
||||
| Gradle plugin assembly with prepared CLI binaries | `./gradlew buildPlugin` |
|
||||
| Gradle plugin assembly with pinned CLI download | `./gradlew buildPlugin` |
|
||||
| Split-mode sandbox | `./gradlew --no-configuration-cache runIdeSplitMode` |
|
||||
| Split backend sandbox | `./gradlew --no-configuration-cache runIdeBackend` |
|
||||
| Monolithic sandbox IDE | `./gradlew runIde` after `bun run build --prepare-cli` |
|
||||
| Monolithic sandbox IDE | `./gradlew runIde` |
|
||||
|
||||
Run `Plugin DevKit | Code | Frontend and Backend API Usage` inspection when moving code across split boundary.
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ Skills are discovered when a session starts. The CLI scans all configured skill
|
||||
- In the **CLI**: Skills are loaded when you start a new session or run `kilo run`
|
||||
- In the **VS Code extension**: Skills are loaded when the extension connects to the CLI server
|
||||
|
||||
Skills are re-scanned at the start of each new session. To pick up newly added or modified skills, start a new session.
|
||||
Skills are re-scanned at the start of each new session. To pick up newly added or modified skills without starting a new session, use `/reload`.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
@@ -242,7 +242,7 @@ Skills are discovered when a session starts. The CLI scans all configured skill
|
||||
- In the **CLI**: Skills are loaded when you start a new session or run `kilo run`
|
||||
- In the **VS Code extension**: Skills are loaded when the extension connects to the CLI server
|
||||
|
||||
Skills are re-scanned at the start of each new session. To pick up newly added or modified skills, start a new session.
|
||||
Skills are re-scanned at the start of each new session. To pick up newly added or modified skills without starting a new session, use `/reload`.
|
||||
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
@@ -430,7 +430,7 @@ The new platform does not have a marketplace UI yet. You can find and share skil
|
||||
|
||||
1. **Verify frontmatter**: Ensure `name` and `description` are present in the YAML frontmatter. The `name` does not need to match the directory name but should be unique across all loaded skills.
|
||||
|
||||
2. **Start a new session**: Skills are scanned at session start. Begin a new session to pick up changes.
|
||||
2. **Reload or start a new session**: Use `/reload` to pick up changes without losing your current session, or start a new session.
|
||||
|
||||
3. **Check file location**: Ensure `SKILL.md` is directly inside the skill directory (e.g., `.kilo/skills/my-skill/SKILL.md`), not nested further.
|
||||
|
||||
@@ -441,7 +441,7 @@ The new platform does not have a marketplace UI yet. You can find and share skil
|
||||
|
||||
1. **Verify frontmatter**: Ensure `name` and `description` are present in the YAML frontmatter. The `name` does not need to match the directory name but should be unique across all loaded skills.
|
||||
|
||||
2. **Start a new session**: Skills are scanned at session start. Begin a new session to pick up changes.
|
||||
2. **Reload or start a new session**: Use `/reload` to pick up changes without losing your current session, or start a new session.
|
||||
|
||||
3. **Check file location**: Ensure `SKILL.md` is directly inside the skill directory (e.g., `.kilo/skills/my-skill/SKILL.md`), not nested further.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Your sessions sync across all of these, so you can start a task on your phone an
|
||||
## Quick Start
|
||||
|
||||
1. [Install Kilo Code](/docs/getting-started/installing) in your preferred environment
|
||||
2. [Connect an AI provider](/docs/ai-providers) or use Kilo's built-in provider & credits
|
||||
2. [Set up authentication](/docs/getting-started/setup-authentication) or use Kilo's built-in provider
|
||||
3. [Run your first task](/docs/getting-started/quickstart)
|
||||
|
||||
{% callout type="tip" %}
|
||||
|
||||
@@ -124,11 +124,10 @@ If you plan to remain on that version for a while, you may also want to temporar
|
||||
|
||||
## Next Steps
|
||||
|
||||
After installation, check out these resources to get started:
|
||||
After installation:
|
||||
|
||||
- [Quickstart Guide](/docs/getting-started/quickstart) - Get up and running in minutes
|
||||
- [Setting Up Authentication](/docs/getting-started/setup-authentication) - Configure your AI provider
|
||||
- [Your First Task](/docs/code-with-ai/agents/chat-interface) - Learn the basics of working with Kilo Code
|
||||
1. **[Set up authentication](/docs/getting-started/setup-authentication)** to configure your AI provider
|
||||
2. **[Run your first task](/docs/getting-started/quickstart)** — Learn how to chat with Kilo to complete tasks
|
||||
|
||||
## Getting Support
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: "Quickstart"
|
||||
title: "Your First Task"
|
||||
description: "Get up and running with Kilo Code in minutes"
|
||||
---
|
||||
|
||||
# Quickstart
|
||||
# Your First Task
|
||||
|
||||
After you [set up Kilo Code](/docs/getting-started/setup-authentication), follow the guide for your platform below.
|
||||
|
||||
{% tabs %}
|
||||
{% tab label="VSCode" %}
|
||||
{% tab label="VS Code" %}
|
||||
|
||||
## Step by Step Guide
|
||||
|
||||
@@ -36,16 +36,9 @@ Kilo Code analyzes your request and proposes actions. By default, most tools are
|
||||
|
||||
To change which actions require approval, open **Settings** (gear icon) and go to the **Auto-Approve** tab. You can set each tool to Allow, Ask, or Deny. See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for details.
|
||||
|
||||
### Step 5: Iterate
|
||||
### Step 5: Iterate and Review
|
||||
|
||||
Kilo Code works iteratively. Continue giving feedback or follow-up instructions until your task is complete.
|
||||
|
||||
### Key Differences from Legacy
|
||||
|
||||
- **Settings** are managed via `kilo.jsonc` config files (the Settings webview reads and writes the same files)
|
||||
- **Permissions** use a granular per-tool system instead of broad approval categories
|
||||
- **Modes** are called "agents" and configured as `.md` files or via the `agent` config key
|
||||
- **Autocomplete** uses FIM (Fill-in-the-Middle) with Codestral
|
||||
Kilo Code works iteratively. Continue giving feedback or follow-up instructions until your task is complete. The assistant will propose file edits, run commands, and complete your request step by step.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
@@ -68,7 +61,7 @@ Run the `kilo` command to start the interactive TUI (terminal user interface):
|
||||
kilo
|
||||
```
|
||||
|
||||
If this is your first time, run `kilo auth login` first to authenticate (see [Setup & Authentication](/docs/getting-started/setup-authentication)).
|
||||
If this is your first time, run `kilo auth login` first to authenticate (see [Authentication](/docs/getting-started/setup-authentication)).
|
||||
|
||||
### Step 3: Type Your Task
|
||||
|
||||
@@ -86,9 +79,9 @@ Kilo analyzes your request and proposes actions. By default, most tools are auto
|
||||
|
||||
To change permission defaults, configure the `permission` key in your `kilo.jsonc` config file. See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for details.
|
||||
|
||||
### Step 5: Iterate
|
||||
### Step 5: Iterate and Review
|
||||
|
||||
Kilo works iteratively. Continue giving feedback or follow-up instructions until your task is complete.
|
||||
Kilo works iteratively. Continue giving feedback or follow-up instructions until your task is complete. The assistant will propose file edits, run commands, and complete your request step by step.
|
||||
|
||||
### One-Shot Mode
|
||||
|
||||
@@ -107,19 +100,13 @@ kilo run --auto "fix the failing tests in test/auth.test.ts"
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
|
||||
## Conclusion
|
||||
## What You Can Do Next
|
||||
|
||||
You've completed your first task. Along the way you learned:
|
||||
Now that you've completed your first task, try these capabilities:
|
||||
|
||||
- How to interact with Kilo Code using natural language
|
||||
- Why approval keeps you in control
|
||||
- How iteration lets the AI refine its work
|
||||
|
||||
Ready for more? Here are some next steps:
|
||||
|
||||
- **[Autocomplete](/docs/code-with-ai/features/autocomplete)** — Get inline code suggestions as you type
|
||||
- **[Agents](/docs/code-with-ai/agents/using-agents)** — Explore different agents for different tasks
|
||||
- **[Git commit generation](/docs/code-with-ai/features/git-commit-generation)** — Automatically generate commit messages
|
||||
- **[Autocomplete](/docs/code-with-ai/features/autocomplete)** — Get inline code suggestions as you type in your editor
|
||||
- **[Agents](/docs/code-with-ai/agents/using-agents)** — Switch between specialized agents for coding, architecture, debugging, and more
|
||||
- **[Git](/docs/code-with-ai/features/git-commit-generation)** — Auto-generate commit messages from your changes
|
||||
|
||||
{% callout type="tip" %}
|
||||
**Accelerate development:** Check out multiple copies of your repository and run Kilo Code on all of them in parallel (using git to resolve any conflicts, same as with human devs). This can dramatically speed up development on large projects.
|
||||
|
||||
@@ -27,6 +27,12 @@ This is especially useful for complex configuration like custom model definition
|
||||
|
||||
## Managing Settings
|
||||
|
||||
Kilo reads JSONC config from a **global** location (`~/.config/kilo/kilo.jsonc`) and from your **project** (`kilo.jsonc`, or `.kilo/kilo.jsonc`). All clients — CLI, VS Code, and JetBrains — read the same files.
|
||||
|
||||
{% callout type="warning" %}
|
||||
**Migrating from opencode?** Kilo no longer falls back to opencode configuration stored in `.opencode` directories (such as `~/.config/opencode` or a project `./.opencode/`). To keep using it, move your global config into `~/.config/kilo/` and any project config into `./.kilo/`.
|
||||
{% /callout %}
|
||||
|
||||
{% tabs %}
|
||||
{% tab label="VSCode" %}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
title: "Setup & Authentication"
|
||||
title: "Authentication"
|
||||
description: "Configure Kilo Code and connect to your AI providers"
|
||||
---
|
||||
|
||||
# Setup & Authentication
|
||||
# Authentication
|
||||
|
||||
When you install Kilo Code, you'll be prompted to sign in or create a free account. This automatically configures everything you need to get started.
|
||||
|
||||
## Quick Start with Kilo Account
|
||||
|
||||
{% tabs %}
|
||||
{% tab label="VSCode" %}
|
||||
{% tab label="VS Code" %}
|
||||
|
||||
The extension prompts you to sign in when you first open Kilo Code in VS Code. Click **Sign In** and complete the browser-based flow. Sign-in applies across extension surfaces, including the sidebar and Agent Manager.
|
||||
|
||||
@@ -93,7 +93,7 @@ Already have a ChatGPT subscription? You can use it with Kilo Code through the [
|
||||
### Configuring Your Provider
|
||||
|
||||
{% tabs %}
|
||||
{% tab label="VSCode" %}
|
||||
{% tab label="VS Code" %}
|
||||
|
||||
1. Open Kilo Code in VS Code
|
||||
2. Click the gear icon ({% codicon name="gear" /%}) in the extension UI to open **Settings**
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2f7edbc1bf6f10260626bf21406bb02e32dc17bf969c83e8b56251c50f755a1f
|
||||
size 7737
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:92cf06c01b19386f2041e378a08afdaf880fdc5c3e19132350fbda05684dbeec
|
||||
size 5775
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a817c2b87e1d7b28a7c28deaa8fd8d1e9c51aa2daabda1a2de4a0db0a33fcc17
|
||||
size 27768
|
||||
oid sha256:ea36a9604fbc773d2312289f46205221e607a927b0d302b1d9a5e6bdc1d80308
|
||||
size 28888
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4dbd3be2cdf72a789c17076afa73ddeb2eacbe201ace8d8a2657eb95b23a413
|
||||
size 53851
|
||||
oid sha256:1ee158333ea6c4478f6c167eac878bdc5969fce47ba9f68a1e731fd9035e9e49
|
||||
size 52312
|
||||
|
||||
@@ -137,6 +137,8 @@
|
||||
<!-- packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx -->
|
||||
- <https://kilo.ai/docs/customize/workflows>
|
||||
<!-- packages/opencode/src/config/config.ts -->
|
||||
- <https://kilo.ai/docs/getting-started/settings>
|
||||
<!-- packages/opencode/src/kilocode/config/config.ts -->
|
||||
- <https://kilo.ai/gateway>
|
||||
<!-- packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx -->
|
||||
- <https://kilo.ai/kiloclaw>
|
||||
|
||||
@@ -25,6 +25,8 @@ export async function fetchProfile(token: string): Promise<KilocodeProfile> {
|
||||
email?: string
|
||||
name?: string
|
||||
organizations?: Organization[]
|
||||
selectedOrganizationId?: string | null
|
||||
hasPersonalAccount?: boolean | null
|
||||
}
|
||||
// Backend returns { user: { email, name, ... }, organizations }
|
||||
// Transform to flat KilocodeProfile structure
|
||||
@@ -32,6 +34,8 @@ export async function fetchProfile(token: string): Promise<KilocodeProfile> {
|
||||
email: data.user?.email ?? data.email ?? "",
|
||||
name: data.user?.name ?? data.name,
|
||||
organizations: data.organizations,
|
||||
selectedOrganizationId: data.selectedOrganizationId ?? undefined,
|
||||
hasPersonalAccount: data.hasPersonalAccount ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +44,21 @@ export async function fetchProfile(token: string): Promise<KilocodeProfile> {
|
||||
*/
|
||||
export const getKiloProfile = fetchProfile
|
||||
|
||||
/**
|
||||
* Resolve the organization a fresh login should default to.
|
||||
*
|
||||
* Applied once at login time (not on every profile fetch): prefer the cloud's
|
||||
* validated `selectedOrganizationId`, and fall back to the first organization
|
||||
* when the account has no personal account to sit on. Returns `undefined` to
|
||||
* mean "personal account".
|
||||
*/
|
||||
export function defaultOrganizationId(profile: KilocodeProfile): string | undefined {
|
||||
const orgs = profile.organizations ?? []
|
||||
const selected = profile.selectedOrganizationId
|
||||
const valid = selected && orgs.some((org) => org.id === selected) ? selected : undefined
|
||||
return valid ?? (profile.hasPersonalAccount === false ? orgs[0]?.id : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user balance from Kilo API
|
||||
* @param token - Authentication token
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFile } from "child_process"
|
||||
import type { DeviceAuthInitiateResponse, DeviceAuthPollResponse } from "../types.js"
|
||||
import { poll } from "./polling.js"
|
||||
import { getKiloProfile, getKiloDefaultModel } from "../api/profile.js"
|
||||
import { getKiloProfile, getKiloDefaultModel, defaultOrganizationId } from "../api/profile.js"
|
||||
import { KILO_API_BASE, POLL_INTERVAL_MS } from "../api/constants.js"
|
||||
import type { AuthOuathResult } from "@kilocode/plugin"
|
||||
|
||||
@@ -127,13 +127,14 @@ export async function authenticateWithDeviceAuthTUI(inputs?: Record<string, stri
|
||||
|
||||
const token = result.token
|
||||
|
||||
// For TUI version, complete with personal account by default
|
||||
// Organization selection is handled by TUI after this callback completes
|
||||
// The TUI will fetch the profile separately and show organization dialog if needed
|
||||
const organizationId = undefined
|
||||
// Apply the cloud-selected organization as the login-time default.
|
||||
// After this, the stored accountId is authoritative; the user can switch
|
||||
// freely and profile fetches never re-derive it from the cloud.
|
||||
const profile = await getKiloProfile(token).catch(() => undefined)
|
||||
const organizationId = profile ? defaultOrganizationId(profile) : undefined
|
||||
|
||||
// Fetch default model
|
||||
const model = await getKiloDefaultModel(token, organizationId)
|
||||
await getKiloDefaultModel(token, organizationId)
|
||||
|
||||
// Return success with OAuth credentials
|
||||
return {
|
||||
@@ -142,6 +143,7 @@ export async function authenticateWithDeviceAuthTUI(inputs?: Record<string, stri
|
||||
refresh: token,
|
||||
access: token,
|
||||
expires: Date.now() + 365 * 24 * 60 * 60 * 1000, // 1 year
|
||||
...(organizationId && { accountId: organizationId }),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
fetchProfileWithBalance,
|
||||
fetchDefaultModel,
|
||||
getKiloProfile,
|
||||
defaultOrganizationId,
|
||||
getKiloBalance,
|
||||
getKiloDefaultModel,
|
||||
promptOrganizationSelection,
|
||||
|
||||
@@ -101,6 +101,8 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
|
||||
email: z.string(),
|
||||
name: z.string().optional(),
|
||||
organizations: z.array(Organization).optional(),
|
||||
selectedOrganizationId: z.string().optional(),
|
||||
hasPersonalAccount: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const Balance = z.object({
|
||||
|
||||
@@ -56,19 +56,25 @@ export function formatProfileInfo(
|
||||
export function getOrganizationOptions(
|
||||
organizations: Organization[],
|
||||
currentOrgId?: string,
|
||||
hasPersonalAccount = true,
|
||||
): Array<{
|
||||
title: string
|
||||
value: string | null
|
||||
description?: string
|
||||
category: string
|
||||
}> {
|
||||
const personal = hasPersonalAccount || organizations.length === 0
|
||||
return [
|
||||
{
|
||||
title: "Personal Account",
|
||||
value: null,
|
||||
description: !currentOrgId ? "→ (current)" : undefined,
|
||||
category: "Accounts",
|
||||
},
|
||||
...(personal
|
||||
? [
|
||||
{
|
||||
title: "Personal Account",
|
||||
value: null,
|
||||
description: !currentOrgId ? "→ (current)" : undefined,
|
||||
category: "Accounts",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...organizations.map((org) => ({
|
||||
title: org.name,
|
||||
value: org.id,
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface KilocodeProfile {
|
||||
email: string
|
||||
name?: string
|
||||
organizations?: Organization[]
|
||||
selectedOrganizationId?: string
|
||||
hasPersonalAccount?: boolean
|
||||
}
|
||||
|
||||
export interface KilocodeBalance {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { defaultOrganizationId } from "../../src/api/profile.js"
|
||||
import type { KilocodeProfile } from "../../src/types.js"
|
||||
|
||||
const profile = (input: Partial<KilocodeProfile> = {}): KilocodeProfile => ({
|
||||
email: "user@example.com",
|
||||
organizations: [{ id: "org_1", name: "Acme", role: "MEMBER" }],
|
||||
...input,
|
||||
})
|
||||
|
||||
describe("defaultOrganizationId", () => {
|
||||
test("defaults to the cloud selected organization", () => {
|
||||
expect(defaultOrganizationId(profile({ selectedOrganizationId: "org_1" }))).toBe("org_1")
|
||||
})
|
||||
|
||||
test("defaults to personal when there is no cloud selection", () => {
|
||||
expect(defaultOrganizationId(profile())).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores a cloud selection that is not one of the user's organizations", () => {
|
||||
expect(defaultOrganizationId(profile({ selectedOrganizationId: "missing" }))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("falls back to the first organization when there is no personal account", () => {
|
||||
expect(
|
||||
defaultOrganizationId(
|
||||
profile({
|
||||
hasPersonalAccount: false,
|
||||
organizations: [
|
||||
{ id: "org_1", name: "Acme", role: "MEMBER" },
|
||||
{ id: "org_2", name: "Beta", role: "MEMBER" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe("org_1")
|
||||
})
|
||||
|
||||
test("prefers a valid cloud selection over the first-organization fallback", () => {
|
||||
expect(
|
||||
defaultOrganizationId(
|
||||
profile({
|
||||
selectedOrganizationId: "org_2",
|
||||
hasPersonalAccount: false,
|
||||
organizations: [
|
||||
{ id: "org_1", name: "Acme", role: "MEMBER" },
|
||||
{ id: "org_2", name: "Beta", role: "MEMBER" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe("org_2")
|
||||
})
|
||||
})
|
||||
@@ -16,3 +16,8 @@ bin/
|
||||
**/.kotlin/
|
||||
**/bin/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Downloaded CLI artifacts
|
||||
**/cli-cache/
|
||||
kilo-*.zip
|
||||
kilo-*.tar.gz
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
- `plugin.xml` `<content>` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`)
|
||||
- Service classes ↔ `<applicationService>`/`<projectService>` entries in the corresponding module XML
|
||||
- `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list
|
||||
- `packages/kilo-jetbrains/package.json` version ↔ GitHub CLI release tag consumed by the backend downloader
|
||||
|
||||
## IntelliJ Platform Source Lookup
|
||||
|
||||
@@ -153,7 +153,9 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
|
||||
|
||||
## CLI Integration
|
||||
|
||||
- CLI process spawning, extraction, and lifecycle belong in `backend`.
|
||||
- CLI process spawning, download, extraction, and lifecycle belong in `backend`.
|
||||
- The plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` for split-mode RPC and runtime use.
|
||||
- The generated API client is produced from the pinned release binary by running `kilo generate` during the Gradle OpenAPI generation task.
|
||||
- For OS and environment checks, prefer IntelliJ Platform classes over raw JVM APIs such as `System.getProperty(...)` or `System.getenv(...)`.
|
||||
- Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`.
|
||||
- Detect OS with `com.intellij.openapi.util.SystemInfo.isMac` / `isLinux` / `isWindows`.
|
||||
@@ -187,22 +189,21 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
|
||||
|
||||
## Build and Verification
|
||||
|
||||
- **Marketplace version build**: Use `script/build-version.sh <version>` from `packages/kilo-jetbrains/` to clean, prepare production CLI binaries, build, sign, and verify the JetBrains Marketplace plugin ZIP. Pass `--skip-verification` only when explicitly needed.
|
||||
- **Marketplace version build**: Use `script/build-version.sh <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. Does NOT require CLI binaries.
|
||||
- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (prepares CLI binaries + runs Gradle `buildPlugin`).
|
||||
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present in `backend/build/generated/cli/`; run `bun run build --prepare-cli` first).
|
||||
- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. A cold build downloads the pinned CLI release via `generateOpenApiSpec` and needs network access; Gradle-cached incremental runs skip the download. It does not bundle per-platform CLI binaries.
|
||||
- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (runs Gradle `buildPlugin`).
|
||||
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/`.
|
||||
- **Java checks**: Do not run `java -version` as a routine preflight. Gradle commands already fail clearly when Java is missing or incompatible; check Java only when diagnosing that failure mode.
|
||||
- **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root.
|
||||
- **Run split mode**: `./gradlew --no-configuration-cache runIdeSplitMode` or the checked-in `Run IDE (Split Mode)` configuration — launches backend and frontend locally and prepares the local-platform CLI binary automatically. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`).
|
||||
- **Run split backend**: `./gradlew --no-configuration-cache runIdeBackend` — prepares the local-platform CLI binary automatically; if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting.
|
||||
- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries.
|
||||
- **Run split mode**: `./gradlew --no-configuration-cache runIdeSplitMode` or the checked-in `Run IDE (Split Mode)` configuration — launches backend and frontend locally. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`).
|
||||
- **Run split backend**: `./gradlew --no-configuration-cache runIdeBackend` — if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting.
|
||||
- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does not build or bundle CLI binaries; the backend downloads the pinned release at connect time.
|
||||
|
||||
### CLI/SDK Change Awareness
|
||||
|
||||
- JetBrains runtime behavior depends on the bundled CLI artifact under `backend/build/generated/cli/`; Gradle-only tasks and sandbox runs do not rebuild it.
|
||||
- If there are relevant changes outside `packages/kilo-jetbrains/` in the CLI (`packages/opencode/`) or SDK/API generation paths (`packages/sdk/js/`, server endpoints, OpenAPI outputs), warn the user that the JetBrains run may be using stale generated CLI or SDK artifacts.
|
||||
- Do not regenerate or rebuild those artifacts automatically just because such changes exist. Ask the user whether to refresh them first, typically with `bun run build --prepare-cli` from `packages/kilo-jetbrains/` for CLI artifacts and `./script/generate.ts` from the repo root for server/API SDK changes.
|
||||
- JetBrains runtime behavior depends on the downloaded CLI release pinned by `packages/kilo-jetbrains/package.json`; local `packages/opencode/` changes are not used unless published and pinned.
|
||||
- If there are relevant server/API changes outside `packages/kilo-jetbrains/`, warn the user that JetBrains may need a newly published/pinned CLI release and regenerated SDK artifacts.
|
||||
|
||||
## UI Guidelines
|
||||
|
||||
|
||||
@@ -152,6 +152,43 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [7.0.2] - 2026-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- First GA release of the native Kilo extension for JetBrains IDEs.
|
||||
- Download the pinned Kilo Core release at runtime instead of bundling CLI binaries, keeping the JetBrains plugin smaller while verifying downloaded archives before use.
|
||||
- Show Kilo Core runtime details from the JetBrains plugin so users can see which Core release is active.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve JetBrains runtime CLI download reliability by pruning stale binaries, using the shell environment for PATH resolution, and surfacing exact release-resolution failures.
|
||||
|
||||
### Changed
|
||||
|
||||
- Polish JetBrains chat UI with auto-collapsing reasoning previews, clearer retry/offline footer state, and more balanced prompt, code, question, todo, history, and popup spacing.
|
||||
- Show the active routed model name and remote status more consistently in CLI runtime surfaces.
|
||||
|
||||
## [7.0.2-rc.2] - 2026-07-07
|
||||
|
||||
### Added
|
||||
- Show compact previews for collapsed reasoning blocks so long assistant reasoning stays readable without taking over the transcript.
|
||||
- Add clearer Kilo Core runtime information and diagnostics for release download failures.
|
||||
|
||||
### Fixed
|
||||
- Resolve the CLI executable using the user's shell environment so custom PATH setups work when sessions start from JetBrains.
|
||||
- Keep retry and offline status visible in the session footer while preserving transcript context.
|
||||
- Prevent oversized header popups by capping preview content.
|
||||
|
||||
### Changed
|
||||
- Download the required Kilo Core release at runtime and prune stale cached runtime binaries automatically.
|
||||
- Polish JetBrains chat spacing, prompt input behavior, question/todo layout, history scrolling, code block padding, and session background colors.
|
||||
|
||||
## [7.0.2-rc.1] - 2026-07-07
|
||||
|
||||
### Added
|
||||
- Download the pinned Kilo Core release at runtime instead of bundling every CLI binary in the JetBrains plugin, keeping the Marketplace package smaller while still verifying downloaded artifacts.
|
||||
|
||||
## [7.0.1] - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -10,7 +10,7 @@ To try the v7 Early Access Program plugin, follow the [JetBrains EAP installatio
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Bun** -- used to build CLI binaries and run build scripts
|
||||
- **Bun** -- used to run package build scripts
|
||||
- **JDK 21+** -- required by Gradle and the IntelliJ Platform SDK. Check with `java -version`. The preferred way to install is via [SDKMAN](https://sdkman.io/install):
|
||||
|
||||
```bash
|
||||
@@ -34,7 +34,7 @@ When working in a git worktree (e.g. via the Agent Manager), run `bun install` f
|
||||
bun install
|
||||
```
|
||||
|
||||
This installs Node dependencies required by the build scripts, including `script/build.ts` which prepares CLI binaries.
|
||||
This installs Node dependencies required by the build scripts.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,7 +52,7 @@ From `packages/kilo-jetbrains/`:
|
||||
bun run build
|
||||
```
|
||||
|
||||
This builds the CLI binary for your current OS/arch only, copies it into the backend module resources, and runs `./gradlew buildPlugin`. The plugin archive is output to `build/distributions/`.
|
||||
This builds the plugin without bundling CLI binaries. The backend downloads the pinned Kilo CLI release for the host platform at connect time. The plugin archive is output to `build/distributions/`.
|
||||
|
||||
Or via Turbo from the repo root:
|
||||
|
||||
@@ -70,7 +70,7 @@ From `packages/kilo-jetbrains/`:
|
||||
bun run build:production
|
||||
```
|
||||
|
||||
This builds CLI binaries for all 6 desktop platforms (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64, windows-arm64), copies them all into the backend jar, and fails if any are missing. Gradle also validates all platforms are present via `-Pproduction=true`.
|
||||
This builds the plugin without bundling CLI binaries. The backend downloads the pinned Kilo CLI release for the host platform at connect time.
|
||||
|
||||
The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.zip`. This zip can be installed in any JetBrains IDE via **Settings > Plugins > Install Plugin from Disk**.
|
||||
|
||||
@@ -84,15 +84,15 @@ See [RELEASING.md](RELEASING.md) for the full release process, including how to
|
||||
|
||||
## Run the plugin
|
||||
|
||||
Use the checked-in `Run IDE (Split Mode)` run configuration (or `./gradlew --no-configuration-cache runIdeSplitMode` from `packages/kilo-jetbrains/`) to launch the backend and frontend halves of a local split-mode sandbox. The task prepares the local-platform CLI binary automatically when `backend/build/generated/cli/cli/` does not contain the expected binary.
|
||||
Use the checked-in `Run IDE (Split Mode)` run configuration (or `./gradlew --no-configuration-cache runIdeSplitMode` from `packages/kilo-jetbrains/`) to launch the backend and frontend halves of a local split-mode sandbox. The backend downloads the pinned CLI release on first connect.
|
||||
|
||||
Use `runIde` only when you need a monolithic sandboxed IntelliJ instance. `runIde` does not prepare the CLI binary automatically, so run `bun run build --prepare-cli` from `packages/kilo-jetbrains/` first to copy the local-platform binary into `backend/build/generated/cli/cli/`.
|
||||
Use `runIde` only when you need a monolithic sandboxed IntelliJ instance.
|
||||
|
||||
Production packaging still requires running `bun run build:production` so all platform binaries are present.
|
||||
Production packaging uses `bun run build:production` and still downloads the host CLI at runtime.
|
||||
|
||||
### Run the split backend
|
||||
|
||||
Use the checked-in `Run IDE (Backend)` run configuration (or `./gradlew --no-configuration-cache runIdeBackend`) to launch just the backend half of a split-mode session. It prepares the local-platform CLI binary automatically when `backend/build/generated/cli/cli/` does not contain the expected binary.
|
||||
Use the checked-in `Run IDE (Backend)` run configuration (or `./gradlew --no-configuration-cache runIdeBackend`) to launch just the backend half of a split-mode session.
|
||||
|
||||
If `Run IDE (Backend)` exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting the backend.
|
||||
|
||||
@@ -107,7 +107,6 @@ All properties below are passed with `-P` on the Gradle command line or in the r
|
||||
| `kilo.splitModeServerPort` | `0` | Backend split-mode server port. `0` or omitted lets the IntelliJ Platform Gradle Plugin pick a free port when the task runs. |
|
||||
| `kilo.dev.storage.isolated` | `false` | When `true`, CLI runs with `XDG_*_HOME` pointing to `.kilo-dev/` in the worktree root, fully isolating dev storage from your real Kilo installation. Enabled by default in the checked-in split-mode run configurations. |
|
||||
| `kilo.dev.worktree.root` | monorepo root | Worktree root used to resolve `.kilo-dev/`. Auto-detected from the Gradle project directory; override only when the auto-detection is wrong. |
|
||||
| `kilo.bun.path` | `bun` on `$PATH` | Absolute path to Bun. Set this when IntelliJ-launched Gradle cannot find Bun automatically. |
|
||||
|
||||
The checked-in IDE run configurations pass `--no-configuration-cache` because the IntelliJ Platform Gradle Plugin run-IDE tasks are not configuration-cache compatible in this setup.
|
||||
They also pass `--purge-old-log-directories` so stale sandbox logs do not hide the current backend and frontend `kilo-dev.log` files.
|
||||
@@ -195,9 +194,7 @@ For direct local packaging, run:
|
||||
bun run build
|
||||
```
|
||||
|
||||
This prepares the local CLI binary and then runs `./gradlew buildPlugin`.
|
||||
|
||||
If you run `./gradlew buildPlugin` directly, Gradle verifies CLI binaries are present but does not build them first. Run `bun run build --prepare-cli` beforehand if the binaries are missing.
|
||||
This runs `./gradlew buildPlugin`.
|
||||
|
||||
For production verification:
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ The published code comes from `jetbrains/v<version>`. Marketplace and GitHub rel
|
||||
|
||||
Maintainers can use the Kilo `release-jetbrains` skill to drive this process from a version request such as `next rc` or an explicit version. The skill resolves and confirms the version, dispatches and watches the prepare workflow, helps produce a filtered human-readable JetBrains/CLI changelog draft, commits the reviewed changelog to the release PR, and watches publishing after the PR is merged.
|
||||
|
||||
JetBrains plugin builds and runtime downloads use the Kilo Core version pinned in `packages/kilo-jetbrains/package.json`, so verify that pin points at a published `v<version>` release before creating the release tag.
|
||||
|
||||
The skill lives at `.kilo/skills/release-jetbrains/SKILL.md`. It does not move or recreate release tags, and merge permission is only required if the user explicitly asks the skill to merge the release PR automatically.
|
||||
|
||||
## Create Release Tag And PR
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import normalization.NormalizeOpenApiSpecTask
|
||||
import org.gradle.api.tasks.WriteProperties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.rpc)
|
||||
@@ -15,18 +16,35 @@ kotlin {
|
||||
val generatedApi = layout.buildDirectory.dir("generated/openapi/src/main/kotlin")
|
||||
val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.json")
|
||||
val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json")
|
||||
val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
|
||||
|
||||
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
|
||||
Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1)
|
||||
?: error("Could not read version from package.json")
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
resources.srcDir(layout.buildDirectory.dir("generated/cli"))
|
||||
resources.srcDir(generatedProps)
|
||||
kotlin.srcDir(generatedApi)
|
||||
}
|
||||
}
|
||||
|
||||
val writeKiloProperties by tasks.registering(WriteProperties::class) {
|
||||
description = "Write pinned Kilo CLI properties"
|
||||
val out = generatedProps.map { it.file("kilo.properties") }
|
||||
destinationFile.set(out)
|
||||
property("cli.version", pinnedCliVersion)
|
||||
}
|
||||
|
||||
val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) {
|
||||
description = "Generate CLI OpenAPI spec into the build directory"
|
||||
opencodeDir.set(rootProject.layout.projectDirectory.dir("../opencode"))
|
||||
serverSrcDir.set(rootProject.layout.projectDirectory.dir("../opencode/src/server"))
|
||||
cliVersion.set(pinnedCliVersion)
|
||||
token.set(
|
||||
providers.environmentVariable("GH_TOKEN")
|
||||
.orElse(providers.environmentVariable("GITHUB_TOKEN"))
|
||||
)
|
||||
cacheDir.set(layout.buildDirectory.dir("cli-cache"))
|
||||
spec.set(rawSpec)
|
||||
}
|
||||
|
||||
@@ -83,49 +101,19 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
|
||||
}
|
||||
|
||||
tasks.named("compileKotlin") {
|
||||
dependsOn(fixGeneratedApi)
|
||||
dependsOn(fixGeneratedApi, writeKiloProperties)
|
||||
inputs.dir(generatedApi)
|
||||
}
|
||||
|
||||
tasks.named("processResources") {
|
||||
dependsOn(writeKiloProperties)
|
||||
}
|
||||
|
||||
tasks.named("compileTestKotlin") {
|
||||
dependsOn(fixGeneratedApi)
|
||||
inputs.dir(generatedApi)
|
||||
}
|
||||
|
||||
val cliDir = layout.buildDirectory.dir("generated/cli/cli")
|
||||
val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false)
|
||||
|
||||
val prepareLocalCli by tasks.registering(PrepareLocalCliTask::class) {
|
||||
description = "Prepare the local-platform CLI binary for JetBrains backend runs"
|
||||
root.set(rootProject.layout.projectDirectory)
|
||||
dir.set(cliDir)
|
||||
bunPath.convention(
|
||||
providers.gradleProperty("kilo.bun.path")
|
||||
.orElse(providers.environmentVariable("BUN_EXE"))
|
||||
)
|
||||
}
|
||||
|
||||
val requiredPlatforms = listOf(
|
||||
"darwin-arm64",
|
||||
"darwin-x64",
|
||||
"linux-arm64",
|
||||
"linux-x64",
|
||||
"windows-x64",
|
||||
"windows-arm64",
|
||||
)
|
||||
|
||||
val prod = production
|
||||
val checkCli by tasks.registering(CheckCliTask::class) {
|
||||
description = "Verify CLI binaries exist before packaging"
|
||||
dir.set(cliDir)
|
||||
this.production.set(prod)
|
||||
platforms.set(requiredPlatforms)
|
||||
}
|
||||
|
||||
// CLI binaries are verified only at packaging time (buildPlugin), not at
|
||||
// processResources time, so that Kotlin compile and tests work without binaries.
|
||||
// Wire checkCli to buildPlugin in the root build.gradle.kts instead.
|
||||
|
||||
dependencies {
|
||||
intellijPlatform {
|
||||
intellijIdea(libs.versions.intellij.platform)
|
||||
@@ -137,6 +125,7 @@ dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.sse)
|
||||
implementation(libs.commons.compress)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ import ai.kilocode.rpc.dto.ConfigDto
|
||||
*/
|
||||
sealed class KiloAppState {
|
||||
data object Disconnected : KiloAppState()
|
||||
data class Downloading(val percent: Int, val version: String, val platform: String) : KiloAppState()
|
||||
data object Connecting : KiloAppState()
|
||||
data class Loading(val progress: LoadProgress) : KiloAppState()
|
||||
data class MigrationRequired(val detection: LegacyMigrationDetection) : KiloAppState()
|
||||
|
||||
+7
-4
@@ -97,7 +97,7 @@ class KiloBackendAppService private constructor(
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val RETRY_DELAY_MS = 1000L
|
||||
private const val APP_LOAD_TIMEOUT_MS = 30_000L
|
||||
private const val READY_TIMEOUT_MS = 5_000L
|
||||
private const val READY_TIMEOUT_MS = 120_000L
|
||||
|
||||
/** Test factory — no IntelliJ deps needed. */
|
||||
internal fun create(
|
||||
@@ -146,7 +146,7 @@ class KiloBackendAppService private constructor(
|
||||
suspend fun connect() {
|
||||
mutex.withLock {
|
||||
val current = _appState.value
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) return
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Downloading || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) return
|
||||
ensureWatcher()
|
||||
connection.connect()
|
||||
}
|
||||
@@ -179,6 +179,7 @@ class KiloBackendAppService private constructor(
|
||||
ensureWatcher()
|
||||
connection.connect()
|
||||
}
|
||||
is KiloAppState.Downloading,
|
||||
KiloAppState.Connecting,
|
||||
is KiloAppState.Loading -> Unit
|
||||
is KiloAppState.MigrationRequired -> {
|
||||
@@ -231,10 +232,11 @@ class KiloBackendAppService private constructor(
|
||||
when (_appState.value) {
|
||||
is KiloAppState.Ready -> return
|
||||
is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required")
|
||||
is KiloAppState.Downloading,
|
||||
is KiloAppState.Loading,
|
||||
KiloAppState.Connecting -> {
|
||||
val state = withTimeoutOrNull(timeoutMs) {
|
||||
appState.first { it !is KiloAppState.Loading && it !is KiloAppState.Connecting }
|
||||
appState.first { it !is KiloAppState.Downloading && it !is KiloAppState.Loading && it !is KiloAppState.Connecting }
|
||||
}
|
||||
when (state) {
|
||||
is KiloAppState.Ready -> return
|
||||
@@ -292,7 +294,7 @@ class KiloBackendAppService private constructor(
|
||||
private suspend fun reconnect() {
|
||||
mutex.withLock {
|
||||
val current = _appState.value
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) {
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Downloading || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) {
|
||||
log.info("reconnect: already ${current::class.simpleName} — skipping")
|
||||
return
|
||||
}
|
||||
@@ -307,6 +309,7 @@ class KiloBackendAppService private constructor(
|
||||
connection.state.collect { next ->
|
||||
when (next) {
|
||||
ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected
|
||||
is ConnectionState.Downloading -> _appState.value = KiloAppState.Downloading(next.percent, next.version, next.platform)
|
||||
ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting
|
||||
is ConnectionState.Connected -> {
|
||||
load()
|
||||
|
||||
+9
-4
@@ -1,8 +1,8 @@
|
||||
package ai.kilocode.backend.app
|
||||
|
||||
import ai.kilocode.backend.cli.CliServer
|
||||
import ai.kilocode.backend.cli.KiloBackendHttpClients
|
||||
import ai.kilocode.backend.cli.KiloCliDataParser
|
||||
import ai.kilocode.backend.cli.CliServer
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.jetbrains.api.client.DefaultApi
|
||||
@@ -32,6 +32,7 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
sealed class ConnectionState {
|
||||
data object Disconnected : ConnectionState()
|
||||
data class Downloading(val percent: Int, val version: String, val platform: String) : ConnectionState()
|
||||
data object Connecting : ConnectionState()
|
||||
data class Connected(val port: Int, val password: String) : ConnectionState()
|
||||
data class Error(val message: String, val details: String? = null) : ConnectionState()
|
||||
@@ -144,7 +145,7 @@ class KiloConnectionService(
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the CLI process, re-extract the binary from JAR, and restart.
|
||||
* Kill the CLI process, re-download the binary, and restart.
|
||||
*
|
||||
* Called under [KiloBackendAppService]'s mutex.
|
||||
*/
|
||||
@@ -153,7 +154,7 @@ class KiloConnectionService(
|
||||
teardown()
|
||||
log.info("reinstall: teardown complete — setting forceExtract flag")
|
||||
server.forceExtract = true
|
||||
log.info("reinstall: spawning new CLI process (binary will be re-extracted)")
|
||||
log.info("reinstall: spawning new CLI process (binary will be re-downloaded)")
|
||||
open()
|
||||
log.info("reinstall: open() returned — CLI process started with fresh binary")
|
||||
}
|
||||
@@ -190,7 +191,10 @@ class KiloConnectionService(
|
||||
|
||||
setState(ConnectionState.Connecting)
|
||||
|
||||
val result = server.init()
|
||||
val result = server.init(
|
||||
onProgress = { item -> setState(ConnectionState.Downloading(item.percent, item.version, item.platform)) },
|
||||
onResolved = { setState(ConnectionState.Connecting) },
|
||||
)
|
||||
|
||||
if (result is CliServer.State.Error) {
|
||||
setState(ConnectionState.Error(result.message, result.details))
|
||||
@@ -198,6 +202,7 @@ class KiloConnectionService(
|
||||
}
|
||||
|
||||
val ready = result as CliServer.State.Ready
|
||||
setState(ConnectionState.Connecting)
|
||||
port = ready.port
|
||||
password = ready.password
|
||||
|
||||
|
||||
+13
-7
@@ -271,8 +271,8 @@ class KiloBackendSessionManager(
|
||||
title = s.title,
|
||||
version = s.version,
|
||||
time = SessionTimeDto(
|
||||
created = s.time.created.toDouble(),
|
||||
updated = s.time.updated.toDouble(),
|
||||
created = time(s.id, "created", s.time.created),
|
||||
updated = time(s.id, "updated", s.time.updated),
|
||||
archived = s.time.archived,
|
||||
),
|
||||
summary = s.summary?.let {
|
||||
@@ -292,15 +292,15 @@ class KiloBackendSessionManager(
|
||||
title = s.title,
|
||||
version = s.version,
|
||||
time = SessionTimeDto(
|
||||
created = s.time.created.toDouble(),
|
||||
updated = s.time.updated.toDouble(),
|
||||
created = time(s.id, "created", s.time.created),
|
||||
updated = time(s.id, "updated", s.time.updated),
|
||||
archived = s.time.archived,
|
||||
),
|
||||
summary = s.summary?.let {
|
||||
SessionSummaryDto(
|
||||
additions = it.additions.safeInt(),
|
||||
deletions = it.deletions.safeInt(),
|
||||
files = it.files.safeInt(),
|
||||
additions = it.additions?.safeInt() ?: 0,
|
||||
deletions = it.deletions?.safeInt() ?: 0,
|
||||
files = it.files?.safeInt() ?: 0,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -315,6 +315,12 @@ class KiloBackendSessionManager(
|
||||
|
||||
private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8)
|
||||
|
||||
private fun time(id: String, field: String, value: Number?): Double {
|
||||
if (value != null) return value.toDouble()
|
||||
log.warn("Session $id missing $field timestamp; defaulting to 0.0")
|
||||
return 0.0
|
||||
}
|
||||
|
||||
private fun escape(value: String) = buildString {
|
||||
for (c in value) {
|
||||
when (c) {
|
||||
|
||||
+7
-1
@@ -13,8 +13,14 @@ interface CliServer {
|
||||
|
||||
var forceExtract: Boolean
|
||||
fun process(): Process?
|
||||
suspend fun init(): State
|
||||
suspend fun init(onProgress: (CliDownload) -> Unit = {}, onResolved: () -> Unit = {}): State
|
||||
fun exited(proc: Process)
|
||||
fun stop()
|
||||
fun dispose()
|
||||
}
|
||||
|
||||
data class CliDownload(
|
||||
val percent: Int,
|
||||
val version: String,
|
||||
val platform: String,
|
||||
)
|
||||
|
||||
+12
-67
@@ -5,8 +5,7 @@ import ai.kilocode.backend.dev.KiloDevMode
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.application.ApplicationInfo
|
||||
import com.intellij.openapi.application.PathManager
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import com.intellij.util.system.CpuArch
|
||||
import com.intellij.util.EnvironmentUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -20,7 +19,7 @@ import java.util.concurrent.TimeUnit
|
||||
/**
|
||||
* Manages the Kilo CLI binary lifecycle.
|
||||
*
|
||||
* Extracts the bundled CLI from JAR resources into IntelliJ's system directory,
|
||||
* Downloads the pinned CLI into IntelliJ's system directory,
|
||||
* spawns `kilo serve --port 0`, and exposes the result as [State].
|
||||
*
|
||||
* Concurrency is handled by the owning [KiloBackendAppService] — all public
|
||||
@@ -50,9 +49,10 @@ class KiloBackendCliManager(
|
||||
|
||||
override fun process(): Process? = process
|
||||
|
||||
override suspend fun init(): CliServer.State {
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State {
|
||||
return try {
|
||||
val path = extractCli()
|
||||
val path = resolveCli(onProgress)
|
||||
onResolved()
|
||||
log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)")
|
||||
withTimeout(STARTUP_TIMEOUT_MS) {
|
||||
spawn(path)
|
||||
@@ -84,55 +84,15 @@ class KiloBackendCliManager(
|
||||
cleanup(proc, "stop()")
|
||||
}
|
||||
|
||||
private fun extractCli(): File {
|
||||
val platform = platform()
|
||||
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
|
||||
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
|
||||
val worker = File(target.parentFile, "kilo-sandbox-mutation-worker.js")
|
||||
|
||||
if (forceExtract) {
|
||||
log.info("Force re-extracting CLI resources under ${target.parentFile.absolutePath}")
|
||||
if (target.exists()) target.delete()
|
||||
if (worker.exists()) worker.delete()
|
||||
forceExtract = false
|
||||
}
|
||||
|
||||
extractResource("cli/$platform/$exe", target, executable = true)
|
||||
if (worker.exists()) worker.delete()
|
||||
extractResource("cli/$platform/kilo-sandbox-mutation-worker.js", worker, executable = false)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun extractResource(resource: String, target: File, executable: Boolean) {
|
||||
val loader = javaClass.classLoader
|
||||
val url = loader.getResource(resource)
|
||||
?: throw IllegalStateException("CLI resource not found in JAR resources at $resource")
|
||||
|
||||
val size = url.openConnection().contentLengthLong
|
||||
if (size >= 0 && target.exists() && target.length() == size) {
|
||||
log.info("CLI resource up-to-date at ${target.absolutePath}")
|
||||
if (executable && !SystemInfo.isWindows) {
|
||||
target.setExecutable(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.info("Extracting CLI resource to ${target.absolutePath}")
|
||||
target.parentFile.mkdirs()
|
||||
|
||||
url.openStream().use { input ->
|
||||
target.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (executable && !SystemInfo.isWindows) {
|
||||
target.setExecutable(true)
|
||||
}
|
||||
private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File {
|
||||
val force = forceExtract
|
||||
forceExtract = false
|
||||
if (force) log.info("Force re-downloading CLI ${KiloProps.cliVersion()}")
|
||||
return KiloCliDownloader(log = log).resolve(KiloProps.cliVersion(), force, onProgress)
|
||||
}
|
||||
|
||||
// Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs).
|
||||
internal fun buildEnv(pwd: String, base: Map<String, String> = System.getenv()): Map<String, String> =
|
||||
internal fun buildEnv(pwd: String, base: Map<String, String> = EnvironmentUtil.getEnvironmentMap()): Map<String, String> =
|
||||
buildKiloCliEnv(pwd, base, log)
|
||||
|
||||
private suspend fun spawn(cli: File): CliServer.State =
|
||||
@@ -267,21 +227,6 @@ class KiloBackendCliManager(
|
||||
runCatching { proc.outputStream.close() }.onFailure { log.info("CLI stdin stream close skipped: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun platform(): String {
|
||||
val os = when {
|
||||
SystemInfo.isMac -> "darwin"
|
||||
SystemInfo.isLinux -> "linux"
|
||||
SystemInfo.isWindows -> "windows"
|
||||
else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}")
|
||||
}
|
||||
val arch = when (CpuArch.CURRENT) {
|
||||
CpuArch.ARM64 -> "arm64"
|
||||
CpuArch.X86_64 -> "x64"
|
||||
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
|
||||
}
|
||||
return "$os-$arch"
|
||||
}
|
||||
|
||||
private fun generatePassword(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
@@ -294,7 +239,7 @@ private const val DEFAULT_CONFIG = """{"permission":{"edit":"ask","bash":"ask"}}
|
||||
// Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs).
|
||||
internal fun buildKiloCliEnv(
|
||||
pwd: String,
|
||||
base: Map<String, String> = System.getenv(),
|
||||
base: Map<String, String> = EnvironmentUtil.getEnvironmentMap(),
|
||||
log: KiloLog = KiloLog.create(KiloBackendCliManager::class.java),
|
||||
): Map<String, String> = buildMap {
|
||||
putAll(base)
|
||||
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.application.PathManager
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class KiloCliDownloader(
|
||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
private val log: KiloLog = KiloLog.create(KiloCliDownloader::class.java),
|
||||
private val root: File = File(PathManager.getSystemPath(), "kilo/cli"),
|
||||
private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download",
|
||||
private val api: String = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags",
|
||||
) {
|
||||
companion object {
|
||||
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private val LOCKS = ConcurrentHashMap<String, Any>()
|
||||
}
|
||||
|
||||
suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File =
|
||||
withContext(Dispatchers.IO) {
|
||||
locked {
|
||||
val platform = KiloCliPlatform.current()
|
||||
val dir = File(File(root, version), platform)
|
||||
val exe = File(dir, "bin/${KiloCliPlatform.exe()}")
|
||||
val done = File(dir, ".complete")
|
||||
val ext = KiloCliPlatform.archive(platform)
|
||||
|
||||
if (!force) {
|
||||
cached(version, platform, exe, done)?.let { return@locked it }
|
||||
}
|
||||
|
||||
val digest = asset(version, platform, ext)
|
||||
val stage = stage(version, platform)
|
||||
try {
|
||||
val archive = File(stage, "kilo-$platform.$ext")
|
||||
val staged = File(stage, "bin/${KiloCliPlatform.exe()}")
|
||||
val complete = File(stage, ".complete")
|
||||
|
||||
log.info(
|
||||
"Kilo CLI $version for $platform is not cached; downloading new release into ${stage.absolutePath}"
|
||||
)
|
||||
onProgress(CliDownload(0, version, platform))
|
||||
download(version, platform, ext, archive, onProgress)
|
||||
verify(archive, digest)
|
||||
log.info(
|
||||
"Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)"
|
||||
)
|
||||
extract(archive, stage)
|
||||
if (!staged.isFile) {
|
||||
throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}")
|
||||
}
|
||||
if (!SystemInfo.isWindows) staged.setExecutable(true)
|
||||
if (archive.exists() && !archive.delete()) {
|
||||
log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}")
|
||||
}
|
||||
complete.writeText("$digest\n")
|
||||
replace(dir, stage)
|
||||
onProgress(CliDownload(100, version, platform))
|
||||
prune(version)
|
||||
exe
|
||||
} finally {
|
||||
if (stage.exists() && !stage.deleteRecursively()) {
|
||||
log.warn("Failed to delete staged Kilo CLI download ${stage.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cached(version: String, platform: String, exe: File, done: File): File? {
|
||||
val digest = done.takeIf { it.isFile }?.readText()?.trim()
|
||||
if (!exe.isFile || digest == null || !digest.matches(DIGEST)) return null
|
||||
log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}")
|
||||
if (!SystemInfo.isWindows) exe.setExecutable(true)
|
||||
prune(version)
|
||||
return exe
|
||||
}
|
||||
|
||||
private fun <T> locked(block: () -> T): T {
|
||||
if (!root.isDirectory && !root.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI cache root ${root.absolutePath}")
|
||||
}
|
||||
val file = File(root, ".lock").canonicalFile
|
||||
val mutex = LOCKS.computeIfAbsent(file.absolutePath) { Any() }
|
||||
return synchronized(mutex) {
|
||||
RandomAccessFile(file, "rw").channel.use { channel ->
|
||||
channel.lock().use { block() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stage(version: String, platform: String): File {
|
||||
val tmp = File(root, ".tmp")
|
||||
val dir = File(tmp, "$version-$platform-${System.nanoTime()}")
|
||||
if (!dir.isDirectory && !dir.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI staging directory ${dir.absolutePath}")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun replace(dir: File, stage: File) {
|
||||
val parent = dir.parentFile
|
||||
if (!parent.isDirectory && !parent.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI cache directory ${parent.absolutePath}")
|
||||
}
|
||||
|
||||
val backup = File(parent, ".${dir.name}.backup-${System.nanoTime()}")
|
||||
if (dir.exists() && !dir.renameTo(backup)) {
|
||||
throw IllegalStateException("Failed to move existing Kilo CLI cache ${dir.absolutePath} aside")
|
||||
}
|
||||
if (stage.renameTo(dir)) {
|
||||
if (backup.exists() && !backup.deleteRecursively()) {
|
||||
log.warn("Failed to delete previous Kilo CLI cache ${backup.absolutePath}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (backup.exists() && !backup.renameTo(dir)) {
|
||||
log.warn("Failed to restore previous Kilo CLI cache ${backup.absolutePath} to ${dir.absolutePath}")
|
||||
}
|
||||
throw IllegalStateException("Failed to install Kilo CLI cache ${stage.absolutePath} to ${dir.absolutePath}")
|
||||
}
|
||||
|
||||
private fun fail(message: String): Nothing {
|
||||
log.warn(message)
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
|
||||
private fun asset(version: String, platform: String, ext: String): String {
|
||||
val name = "kilo-$platform.$ext"
|
||||
val url = "${api.trimEnd('/')}/v$version"
|
||||
log.info("Fetching Kilo CLI release metadata for $version from $url")
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val info = rate(response)
|
||||
val body = runCatching { response.body?.string() }.getOrNull()?.take(500)
|
||||
val detail = if (body.isNullOrBlank()) "" else ": $body"
|
||||
if (limited(response)) {
|
||||
log.warn("GitHub API rate limit hit fetching Kilo CLI $version metadata from $url ($info)$detail")
|
||||
throw IllegalStateException(
|
||||
"GitHub API rate limit exceeded while resolving Kilo CLI $version ($info)$detail"
|
||||
)
|
||||
}
|
||||
log.warn("Failed to fetch Kilo CLI $version metadata from $url: HTTP ${response.code} ($info)$detail")
|
||||
throw IllegalStateException(
|
||||
"Failed to fetch Kilo CLI release metadata for $version: HTTP ${response.code} ($info)$detail"
|
||||
)
|
||||
}
|
||||
log.debug { "GitHub metadata OK for Kilo CLI $version (${rate(response)})" }
|
||||
val body = response.body?.string()
|
||||
?: throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: empty response body")
|
||||
val assets = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
|
||||
val entry = assets?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name }
|
||||
if (entry == null) {
|
||||
val names = assets?.mapNotNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull }?.joinToString(", ")
|
||||
fail("Kilo CLI release $version has no asset named $name (available assets: ${names ?: "none"})")
|
||||
}
|
||||
val digest = entry.jsonObject["digest"]?.jsonPrimitive?.contentOrNull
|
||||
if (digest == null) {
|
||||
fail("Kilo CLI release $version asset $name has no digest yet; GitHub has not published a SHA-256 checksum for it")
|
||||
}
|
||||
if (!digest.matches(DIGEST)) {
|
||||
fail("Kilo CLI release $version asset $name has a malformed digest '$digest'; expected sha256:<64 hex chars>")
|
||||
}
|
||||
return digest
|
||||
}
|
||||
}
|
||||
|
||||
private fun rate(response: Response): String {
|
||||
val reset = response.header("X-RateLimit-Reset")
|
||||
?.toLongOrNull()
|
||||
?.let { Instant.ofEpochSecond(it).toString() }
|
||||
return "limit=${response.header("X-RateLimit-Limit")} remaining=${response.header("X-RateLimit-Remaining")} " +
|
||||
"used=${response.header("X-RateLimit-Used")} reset=$reset retryAfter=${response.header("Retry-After")}"
|
||||
}
|
||||
|
||||
private fun limited(response: Response) =
|
||||
response.code == 429 || (response.code == 403 && response.header("X-RateLimit-Remaining") == "0")
|
||||
|
||||
private fun download(version: String, platform: String, ext: String, file: File, onProgress: (CliDownload) -> Unit) {
|
||||
val url = url(version, platform, ext)
|
||||
log.info("Downloading Kilo CLI $version for $platform from $url")
|
||||
val request = Request.Builder().url(url).build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Failed to download Kilo CLI $version for $platform: HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body ?: throw IllegalStateException("Failed to download Kilo CLI $version: empty response body")
|
||||
val total = body.contentLength()
|
||||
var read = 0L
|
||||
var last = 0
|
||||
file.outputStream().use { output ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
output.write(buffer, 0, n)
|
||||
read += n
|
||||
if (total > 0) {
|
||||
val pct = ((read.toDouble() / total.toDouble()) * 100.0).roundToInt().coerceIn(0, 100)
|
||||
if (pct != last) {
|
||||
last = pct
|
||||
onProgress(CliDownload(pct, version, platform))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(file: File, digest: String) {
|
||||
val actual = sum(file)
|
||||
if (actual == digest) return
|
||||
if (file.exists() && !file.delete()) log.warn("Failed to delete invalid Kilo CLI archive ${file.absolutePath}")
|
||||
throw IllegalStateException("Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
|
||||
}
|
||||
|
||||
private fun sum(file: File) = "sha256:${sha256(file)}"
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
md.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
private fun extract(file: File, dir: File) {
|
||||
log.info("Extracting Kilo CLI archive ${file.absolutePath}")
|
||||
if (file.name.endsWith(".zip")) {
|
||||
ZipInputStream(file.inputStream().buffered()).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
write(dir, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar ->
|
||||
while (true) {
|
||||
val entry = tar.nextEntry ?: break
|
||||
write(dir, entry.name, entry.isDirectory) { out -> tar.copyTo(out) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(dir: File, name: String, directory: Boolean, copy: (java.io.OutputStream) -> Unit) {
|
||||
val path = if (name.startsWith("bin/")) name else "bin/$name"
|
||||
val target = File(dir, path).canonicalFile
|
||||
val base = dir.canonicalFile
|
||||
if (target != base && !target.path.startsWith(base.path + File.separator)) {
|
||||
throw IllegalStateException("Archive entry escapes target directory: $name")
|
||||
}
|
||||
if (directory) {
|
||||
target.mkdirs()
|
||||
return
|
||||
}
|
||||
target.parentFile.mkdirs()
|
||||
target.outputStream().use(copy)
|
||||
if (!SystemInfo.isWindows && (target.name == "kilo" || target.name == "bwrap")) {
|
||||
target.setExecutable(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every cached CLI version under [root] except [keep]. Each plugin update pins a new
|
||||
* CLI version, so without this old versions would accumulate in the IDE system directory
|
||||
* indefinitely. Runs only after the active version resolved successfully so a failed download
|
||||
* never wipes the last working copy.
|
||||
*/
|
||||
private fun prune(keep: String) {
|
||||
val entries = root.listFiles() ?: return
|
||||
for (entry in entries) {
|
||||
if (!entry.isDirectory || entry.name == keep || entry.name.startsWith(".")) continue
|
||||
log.info("Removing stale Kilo CLI version ${entry.absolutePath}")
|
||||
if (!entry.deleteRecursively()) {
|
||||
log.warn("Failed to remove stale Kilo CLI version ${entry.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun url(version: String, platform: String, ext: String) =
|
||||
"${baseUrl.trimEnd('/')}/v$version/kilo-$platform.$ext"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import com.intellij.util.system.CpuArch
|
||||
|
||||
internal object KiloCliPlatform {
|
||||
fun current(): String {
|
||||
val os = when {
|
||||
SystemInfo.isMac -> "darwin"
|
||||
SystemInfo.isLinux -> "linux"
|
||||
SystemInfo.isWindows -> "windows"
|
||||
else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}")
|
||||
}
|
||||
val arch = when (CpuArch.CURRENT) {
|
||||
CpuArch.ARM64 -> "arm64"
|
||||
CpuArch.X86_64 -> "x64"
|
||||
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
|
||||
}
|
||||
return "$os-$arch"
|
||||
}
|
||||
|
||||
fun exe(): String = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
|
||||
|
||||
fun archive(platform: String = current()): String =
|
||||
if (platform.startsWith("linux-")) "tar.gz" else "zip"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import java.util.Properties
|
||||
|
||||
object KiloProps {
|
||||
private val props by lazy {
|
||||
val stream = KiloProps::class.java.classLoader.getResourceAsStream("kilo.properties")
|
||||
?: throw IllegalStateException("kilo.properties resource not found")
|
||||
stream.use {
|
||||
Properties().apply { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun cliVersion(): String = props.getProperty("cli.version")
|
||||
?: throw IllegalStateException("cli.version missing from kilo.properties")
|
||||
}
|
||||
+23
-4
@@ -9,6 +9,8 @@ import ai.kilocode.backend.app.ConfigWarning
|
||||
import ai.kilocode.backend.app.LoadError
|
||||
import ai.kilocode.backend.app.LoadProgress
|
||||
import ai.kilocode.backend.app.ProfileResult
|
||||
import ai.kilocode.backend.cli.KiloCliPlatform
|
||||
import ai.kilocode.backend.cli.KiloProps
|
||||
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
|
||||
import ai.kilocode.rpc.dto.ConfigPatchDto
|
||||
import ai.kilocode.rpc.KiloAppRpcApi
|
||||
@@ -51,6 +53,10 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
|
||||
|
||||
override suspend fun health(): HealthDto = app.health()
|
||||
|
||||
override suspend fun cliVersion(): String = KiloProps.cliVersion()
|
||||
|
||||
override suspend fun cliPlatform(): String = KiloCliPlatform.current()
|
||||
|
||||
override suspend fun retry() = app.retry()
|
||||
|
||||
override suspend fun restart() = app.restart()
|
||||
@@ -109,6 +115,12 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
|
||||
internal fun appStateDto(state: KiloAppState): KiloAppStateDto =
|
||||
when (state) {
|
||||
KiloAppState.Disconnected -> KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
|
||||
is KiloAppState.Downloading -> KiloAppStateDto(
|
||||
status = KiloAppStatusDto.DOWNLOADING,
|
||||
downloadPercent = state.percent,
|
||||
downloadVersion = state.version,
|
||||
downloadPlatform = state.platform,
|
||||
)
|
||||
KiloAppState.Connecting -> KiloAppStateDto(KiloAppStatusDto.CONNECTING)
|
||||
is KiloAppState.Loading -> KiloAppStateDto(
|
||||
status = KiloAppStatusDto.LOADING,
|
||||
@@ -143,12 +155,19 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto(
|
||||
organizations = p.profile.organizations.orEmpty().map { org ->
|
||||
ProfileOrganizationDto(id = org.id, name = org.name, role = org.role)
|
||||
},
|
||||
balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) },
|
||||
// The pinned CLI release does not expose hasPersonalAccount yet, so default to
|
||||
// showing the personal account. Flip back to p.profile.hasPersonalAccount once a
|
||||
// CLI release ships the field.
|
||||
hasPersonalAccount = true,
|
||||
balance = p.balance?.balance?.let { ProfileBalanceDto(balance = it) },
|
||||
kiloPass = p.kiloPass?.let {
|
||||
val base = it.currentPeriodBaseCreditsUsd ?: return@let null
|
||||
val usage = it.currentPeriodUsageUsd ?: return@let null
|
||||
val bonus = it.currentPeriodBonusCreditsUsd ?: return@let null
|
||||
ProfileKiloPassDto(
|
||||
currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd,
|
||||
currentPeriodUsageUsd = it.currentPeriodUsageUsd,
|
||||
currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd,
|
||||
currentPeriodBaseCreditsUsd = base,
|
||||
currentPeriodUsageUsd = usage,
|
||||
currentPeriodBonusCreditsUsd = bonus,
|
||||
nextBillingAt = it.nextBillingAt,
|
||||
)
|
||||
},
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ class KiloAppStateTest {
|
||||
@Test
|
||||
fun `KiloAppState sealed subtypes are distinct`() {
|
||||
assertIs<KiloAppState.Disconnected>(KiloAppState.Disconnected)
|
||||
assertIs<KiloAppState.Downloading>(KiloAppState.Downloading(42, "1.2.3", "darwin-arm64"))
|
||||
assertIs<KiloAppState.Connecting>(KiloAppState.Connecting)
|
||||
assertIs<KiloAppState.Loading>(KiloAppState.Loading(LoadProgress()))
|
||||
assertIs<KiloAppState.Error>(KiloAppState.Error("fail"))
|
||||
|
||||
+72
-2
@@ -3,18 +3,21 @@ package ai.kilocode.backend.app
|
||||
import ai.kilocode.backend.app.KiloAppState
|
||||
import ai.kilocode.backend.app.KiloBackendAppService
|
||||
import ai.kilocode.backend.cli.CliServer
|
||||
import ai.kilocode.backend.cli.CliDownload
|
||||
import ai.kilocode.backend.rpc.appStateDto
|
||||
import ai.kilocode.backend.testing.FakeCliServer
|
||||
import ai.kilocode.backend.testing.MockCliServer
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import ai.kilocode.rpc.dto.AgentConfigPatchDto
|
||||
import ai.kilocode.rpc.dto.ConfigPatchDto
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
@@ -67,7 +70,8 @@ class KiloBackendAppServiceTest {
|
||||
|
||||
override fun process(): Process? = null
|
||||
|
||||
override suspend fun init(): CliServer.State {
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State {
|
||||
onResolved()
|
||||
if (starts.getAndIncrement() == 0) {
|
||||
val socket = ServerSocket(0)
|
||||
srv = socket
|
||||
@@ -102,6 +106,46 @@ class KiloBackendAppServiceTest {
|
||||
assertNotNull(ready.data.notifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `download progress maps to app state before ready`() = runBlocking {
|
||||
val resolved = CompletableDeferred<Unit>()
|
||||
val signal = CompletableDeferred<Unit>()
|
||||
val server = object : CliServer {
|
||||
override var forceExtract = false
|
||||
override fun process(): Process? = null
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State {
|
||||
onProgress(CliDownload(37, "1.2.3", "darwin-arm64"))
|
||||
resolved.await()
|
||||
onResolved()
|
||||
signal.await()
|
||||
return CliServer.State.Ready(mock.start(), mock.password)
|
||||
}
|
||||
override fun exited(proc: Process) {}
|
||||
override fun stop() {}
|
||||
override fun dispose() {}
|
||||
}
|
||||
val svc = KiloBackendAppService.create(scope, server, log)
|
||||
val job = scope.launch { svc.connect() }
|
||||
|
||||
val downloading = withTimeout(5_000) {
|
||||
svc.appState.first { it is KiloAppState.Downloading }
|
||||
}
|
||||
assertEquals(KiloAppState.Downloading(37, "1.2.3", "darwin-arm64"), downloading)
|
||||
val dto = appStateDto(downloading)
|
||||
assertEquals(37, dto.downloadPercent)
|
||||
assertEquals("1.2.3", dto.downloadVersion)
|
||||
assertEquals("darwin-arm64", dto.downloadPlatform)
|
||||
|
||||
resolved.complete(Unit)
|
||||
withTimeout(5_000) {
|
||||
svc.appState.first { it == KiloAppState.Connecting }
|
||||
}
|
||||
|
||||
signal.complete(Unit)
|
||||
ready(svc)
|
||||
job.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shutdown for unload clears runtime and disposes server once`() = runBlocking {
|
||||
val server = FakeCliServer(mock)
|
||||
@@ -389,7 +433,7 @@ class KiloBackendAppServiceTest {
|
||||
val failing = object : ai.kilocode.backend.cli.CliServer {
|
||||
override var forceExtract = false
|
||||
override fun process(): Process? = null
|
||||
override suspend fun init() = ai.kilocode.backend.cli.CliServer.State.Error(
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit) = ai.kilocode.backend.cli.CliServer.State.Error(
|
||||
message = "CLI startup failed",
|
||||
details = "stderr: missing dependency",
|
||||
)
|
||||
@@ -804,10 +848,36 @@ class KiloBackendAppServiceTest {
|
||||
assertEquals("alice@test.com", dto.profile?.email)
|
||||
assertEquals("Alice", dto.profile?.name)
|
||||
assertEquals("ADMIN", dto.profile?.organizations?.firstOrNull()?.role)
|
||||
// The pinned CLI does not expose hasPersonalAccount yet; the mapper defaults it to true.
|
||||
assertTrue(dto.profile?.hasPersonalAccount ?: false)
|
||||
assertEquals(42.5, dto.profile?.balance?.balance)
|
||||
assertEquals("org_1", dto.profile?.currentOrgId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ready dto hides unknown profile amounts`() = runBlocking {
|
||||
mock.profile = """{
|
||||
"profile":{"email":"alice@test.com","name":"Alice"},
|
||||
"balance":{"balance":null},
|
||||
"kiloPass":{
|
||||
"currentPeriodBaseCreditsUsd":null,
|
||||
"currentPeriodUsageUsd":null,
|
||||
"currentPeriodBonusCreditsUsd":null,
|
||||
"nextBillingAt":null
|
||||
},
|
||||
"currentOrgId":null
|
||||
}""".trimIndent()
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
ready(svc)
|
||||
|
||||
val dto = appStateDto(svc.appState.value)
|
||||
assertEquals("alice@test.com", dto.profile?.email)
|
||||
assertNull(dto.profile?.balance)
|
||||
assertNull(dto.profile?.kiloPass)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh profile updates ready dto profile`() = runBlocking {
|
||||
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
|
||||
|
||||
+42
-1
@@ -1,12 +1,14 @@
|
||||
package ai.kilocode.backend.app
|
||||
|
||||
import ai.kilocode.backend.cli.CliServer
|
||||
import ai.kilocode.backend.cli.CliDownload
|
||||
import ai.kilocode.backend.app.ConnectionState
|
||||
import ai.kilocode.backend.app.KiloConnectionService
|
||||
import ai.kilocode.backend.testing.FakeCliServer
|
||||
import ai.kilocode.backend.testing.MockCliServer
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import ai.kilocode.log.KiloLog
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -78,6 +80,45 @@ class KiloConnectionServiceTest {
|
||||
assertTrue(svc.api != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect reports downloading then connecting before ready`() = runBlocking {
|
||||
val resolved = CompletableDeferred<Unit>()
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
val server = object : CliServer {
|
||||
override var forceExtract = false
|
||||
override fun process(): Process? = null
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State {
|
||||
onProgress(CliDownload(42, "1.2.3", "darwin-arm64"))
|
||||
resolved.await()
|
||||
onResolved()
|
||||
ready.await()
|
||||
return CliServer.State.Ready(mock.start(), mock.password)
|
||||
}
|
||||
override fun exited(proc: Process) {}
|
||||
override fun stop() {}
|
||||
override fun dispose() {}
|
||||
}
|
||||
val svc = KiloConnectionService(scope, server, {}, log)
|
||||
val job = scope.launch { svc.connect() }
|
||||
|
||||
val downloading = withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Downloading }
|
||||
}
|
||||
assertEquals(ConnectionState.Downloading(42, "1.2.3", "darwin-arm64"), downloading)
|
||||
|
||||
resolved.complete(Unit)
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it == ConnectionState.Connecting }
|
||||
}
|
||||
|
||||
ready.complete(Unit)
|
||||
mock.awaitSseConnection()
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Connected }
|
||||
}
|
||||
job.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSE events are emitted`() = runBlocking {
|
||||
val svc = KiloConnectionService(scope, fake, {}, log)
|
||||
@@ -189,7 +230,7 @@ class KiloConnectionServiceTest {
|
||||
val failing = object : CliServer {
|
||||
override var forceExtract = false
|
||||
override fun process(): Process? = null
|
||||
override suspend fun init() = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2")
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit) = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2")
|
||||
override fun exited(proc: Process) {}
|
||||
override fun stop() {}
|
||||
override fun dispose() {}
|
||||
|
||||
+9
@@ -79,6 +79,15 @@ class KiloBackendCliManagerEnvTest {
|
||||
assertEquals(cfg, env["KILO_CONFIG_CONTENT"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isolation disabled - base PATH is preserved`() {
|
||||
val path = "/opt/homebrew/bin:/usr/bin"
|
||||
|
||||
val env = manager.buildEnv("pwd123", mapOf("PATH" to path))
|
||||
|
||||
assertEquals(path, env["PATH"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isolation disabled - no XDG storage overrides are injected`() {
|
||||
val env = manager.buildEnv("pwd123", emptyMap())
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KiloCliDownloaderTest {
|
||||
@TempDir
|
||||
lateinit var dir: File
|
||||
|
||||
@Test
|
||||
fun `downloads extracts and caches pinned cli`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val seen = mutableListOf<CliDownload>()
|
||||
val log = TestLog()
|
||||
val cli = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", onProgress = { seen.add(it) })
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
assertEquals(File(File(dir, "1.2.3"), KiloCliPlatform.current()).absolutePath, cli.parentFile.parentFile.absolutePath)
|
||||
assertEquals("#!/bin/sh\n", cli.readText())
|
||||
assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile)
|
||||
assertEquals("/api/v1.2.3", server.takeRequest().path)
|
||||
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
|
||||
assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first())
|
||||
assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() })
|
||||
assertTrue(
|
||||
log.messages.any {
|
||||
it.startsWith("INFO: Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} is not cached; downloading new release into ") &&
|
||||
it.contains("/.tmp/")
|
||||
}
|
||||
)
|
||||
|
||||
val cachedProgress = mutableListOf<CliDownload>()
|
||||
val cached = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", onProgress = { cachedProgress.add(it) })
|
||||
assertEquals(cli.absolutePath, cached.absolutePath)
|
||||
assertEquals(2, server.requestCount)
|
||||
assertTrue(cachedProgress.isEmpty())
|
||||
assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}")
|
||||
|
||||
File(cli.parentFile.parentFile, ".complete").writeText("ok\n")
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val stale = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
assertEquals(cli.absolutePath, stale.absolutePath)
|
||||
assertEquals(4, server.requestCount)
|
||||
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val forced = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", force = true)
|
||||
assertEquals(cli.absolutePath, forced.absolutePath)
|
||||
assertEquals(6, server.requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prunes stale versions and removes the extracted archive`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val stale = File(File(dir, "0.0.1"), KiloCliPlatform.current())
|
||||
assertTrue(stale.mkdirs())
|
||||
File(stale, "leftover").writeText("old")
|
||||
|
||||
val bytes = archive()
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val cli = KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
assertFalse(File(dir, "0.0.1").exists())
|
||||
val archived = File(cli.parentFile.parentFile, "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}")
|
||||
assertFalse(archived.exists())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forced resolve re-downloads and keeps only the active version`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val first = archive("#!/bin/old\n")
|
||||
val next = archive("#!/bin/new\n")
|
||||
server.enqueue(metadata(first))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(first)))
|
||||
server.enqueue(metadata(next))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(next)))
|
||||
val cli = KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
)
|
||||
val old = cli.resolve("1.2.3")
|
||||
assertEquals("#!/bin/old\n", old.readText())
|
||||
assertEquals(2, server.requestCount)
|
||||
|
||||
val forced = cli.resolve("1.2.3", force = true)
|
||||
assertTrue(forced.isFile)
|
||||
assertEquals("#!/bin/new\n", forced.readText())
|
||||
assertEquals(4, server.requestCount)
|
||||
assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory && !it.name.startsWith(".") }?.map { it.name })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forced resolve keeps the existing cli when download fails`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive("#!/bin/old\n")
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(503).setBody("unavailable"))
|
||||
|
||||
val cli = KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", force = true)
|
||||
}
|
||||
|
||||
assertContains(ex.message.orEmpty(), "Failed to download")
|
||||
assertTrue(cli.isFile)
|
||||
assertEquals("#!/bin/old\n", cli.readText())
|
||||
assertTrue(File(cli.parentFile.parentFile, ".complete").isFile)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects cli archive with mismatched digest`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
server.enqueue(metadata("sha256:${sha256("different".toByteArray())}"))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
|
||||
assertContains(ex.message.orEmpty(), "digest mismatch")
|
||||
assertFalse(File(File(File(dir, "1.2.3"), KiloCliPlatform.current()), ".complete").exists())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails clearly and logs when the release has no matching asset`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"assets":[{"name":"other.zip","digest":"sha256:${"a".repeat(64)}"}]}"""
|
||||
)
|
||||
)
|
||||
val log = TestLog()
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
val name = "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}"
|
||||
assertContains(ex.message.orEmpty(), "has no asset named $name")
|
||||
assertContains(ex.message.orEmpty(), "other.zip")
|
||||
assertTrue(log.messages.any { it.contains("has no asset named $name") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails clearly and logs when the asset has no digest`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}"}]}"""
|
||||
)
|
||||
)
|
||||
val log = TestLog()
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
assertContains(ex.message.orEmpty(), "has no digest yet")
|
||||
assertTrue(log.messages.any { it.contains("has no digest yet") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports github api rate limits while resolving metadata`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val reset = 1_800_000_000L
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(403)
|
||||
.setHeader("X-RateLimit-Limit", "60")
|
||||
.setHeader("X-RateLimit-Remaining", "0")
|
||||
.setHeader("X-RateLimit-Used", "60")
|
||||
.setHeader("X-RateLimit-Reset", reset.toString())
|
||||
.setBody("API rate limit exceeded")
|
||||
)
|
||||
val log = TestLog()
|
||||
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
|
||||
assertContains(ex.message.orEmpty(), "GitHub API rate limit exceeded")
|
||||
assertContains(ex.message.orEmpty(), "remaining=0")
|
||||
assertContains(ex.message.orEmpty(), "reset=2027-01-15T08:00:00Z")
|
||||
assertTrue(log.messages.any { it.contains("GitHub API rate limit hit") && it.contains("remaining=0") })
|
||||
assertEquals("/api/v1.2.3", server.takeRequest().path)
|
||||
assertEquals(1, server.requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun archive(script: String = "#!/bin/sh\n"): ByteArray {
|
||||
val files = mapOf(
|
||||
"bin/${KiloCliPlatform.exe()}" to script.toByteArray(),
|
||||
"bin/kilo-sandbox-mutation-worker.js" to "worker\n".toByteArray(),
|
||||
)
|
||||
if (KiloCliPlatform.archive() == "zip") return zip(files)
|
||||
return tar(files)
|
||||
}
|
||||
|
||||
private fun metadata(bytes: ByteArray) = metadata("sha256:${sha256(bytes)}")
|
||||
|
||||
private fun metadata(digest: String) = MockResponse().setResponseCode(200).setBody(
|
||||
"""{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}","digest":"$digest"}]}"""
|
||||
)
|
||||
|
||||
private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(bytes)
|
||||
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
|
||||
private fun zip(files: Map<String, ByteArray>): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
ZipOutputStream(out).use { zip ->
|
||||
files.forEach { entry ->
|
||||
zip.putNextEntry(ZipEntry(entry.key))
|
||||
zip.write(entry.value)
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun tar(files: Map<String, ByteArray>): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
GzipCompressorOutputStream(out).use { gzip ->
|
||||
TarArchiveOutputStream(gzip).use { tar ->
|
||||
files.forEach { entry ->
|
||||
val item = TarArchiveEntry(entry.key)
|
||||
item.size = entry.value.size.toLong()
|
||||
tar.putArchiveEntry(item)
|
||||
tar.write(entry.value)
|
||||
tar.closeArchiveEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
package ai.kilocode.backend.testing
|
||||
|
||||
import ai.kilocode.backend.cli.CliServer
|
||||
import ai.kilocode.backend.cli.CliDownload
|
||||
|
||||
/**
|
||||
* Fake [CliServer] that delegates to a [MockCliServer] instead of
|
||||
@@ -20,8 +21,10 @@ class FakeCliServer(private val mock: MockCliServer) : CliServer {
|
||||
|
||||
override fun process(): Process? = null
|
||||
|
||||
override suspend fun init(): CliServer.State =
|
||||
CliServer.State.Ready(mock.start(), mock.password)
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State {
|
||||
onResolved()
|
||||
return CliServer.State.Ready(mock.start(), mock.password)
|
||||
}
|
||||
|
||||
override fun exited(proc: Process) {}
|
||||
|
||||
|
||||
+14
@@ -404,6 +404,20 @@ class KiloBackendWorkspaceTest {
|
||||
assertEquals("ses_1", result.sessions[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workspace maps missing session timestamps to zero`() = runBlocking {
|
||||
mock.sessions = """[
|
||||
{"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":null,"updated":null}}
|
||||
]"""
|
||||
val app = setup()
|
||||
val ws = ready(app)
|
||||
loaded(ws)
|
||||
|
||||
val session = ws.sessions().sessions.single()
|
||||
assertEquals(0.0, session.time.created)
|
||||
assertEquals(0.0, session.time.updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workspace creates session in its directory`() = runBlocking {
|
||||
mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}"""
|
||||
|
||||
@@ -8,6 +8,7 @@ repositories {
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.commons.compress)
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import org.gradle.api.Project
|
||||
* `id("build-tasks")` resolves in `backend/build.gradle.kts`.
|
||||
*
|
||||
* The real value lives in the custom task classes this composite build
|
||||
* provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask],
|
||||
* [PrepareLocalCliTask], and [CheckCliTask].
|
||||
* provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask], and
|
||||
* [GenerateOpenApiSpecTask].
|
||||
*/
|
||||
class BuildTasksPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.provider.ListProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputDirectory
|
||||
import org.gradle.api.tasks.Optional
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Verify that CLI binaries exist before packaging the plugin.
|
||||
* In production mode, all platform binaries must be present.
|
||||
* In dev mode, only the current platform binary is required.
|
||||
*
|
||||
* CLI binaries must be prepared separately before packaging:
|
||||
* Local: bun run build (from packages/kilo-jetbrains/)
|
||||
* Production: bun run build:production
|
||||
*/
|
||||
abstract class CheckCliTask : DefaultTask() {
|
||||
@get:InputDirectory
|
||||
@get:Optional
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val dir: DirectoryProperty
|
||||
|
||||
@get:Input
|
||||
abstract val production: Property<Boolean>
|
||||
|
||||
@get:Input
|
||||
abstract val platforms: ListProperty<String>
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val resolved = dir.get().asFile
|
||||
if (!resolved.exists() || resolved.listFiles()?.isEmpty() != false) {
|
||||
throw GradleException(
|
||||
"CLI binaries not found at ${resolved.absolutePath}.\n" +
|
||||
"Run 'bun run build' from packages/kilo-jetbrains/ to build CLI and plugin together."
|
||||
)
|
||||
}
|
||||
if (production.get()) {
|
||||
val missing = platforms.get().filter { platform ->
|
||||
val d = File(resolved, platform)
|
||||
val exe = if (platform.startsWith("windows")) "kilo.exe" else "kilo"
|
||||
!File(d, exe).exists()
|
||||
}
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"Production build requires all platform CLI binaries.\n" +
|
||||
"Missing: ${missing.joinToString(", ")}\n" +
|
||||
"Run 'bun run build:production' to build all platforms."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,48 @@
|
||||
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.tasks.InputDirectory
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.Internal
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import javax.inject.Inject
|
||||
import org.gradle.process.ExecOperations
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Generates the CLI OpenAPI spec into the build directory so the JetBrains
|
||||
* Gradle build is self-contained and does not mutate the tracked
|
||||
* packages/sdk/openapi.json.
|
||||
*
|
||||
* Runs `bun dev generate` from the opencode package directory and captures
|
||||
* stdout to [spec]. stderr is captured separately and included in the error
|
||||
* message on failure.
|
||||
*
|
||||
* Gradle up-to-date tracking is scoped to [serverSrcDir] (the opencode server
|
||||
* source) to avoid busting the cache on unrelated changes to dist/, node_modules/,
|
||||
* etc.
|
||||
* Generates the CLI OpenAPI spec from the pinned release binary.
|
||||
*/
|
||||
abstract class GenerateOpenApiSpecTask : DefaultTask() {
|
||||
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"
|
||||
}
|
||||
|
||||
/**
|
||||
* The server source directory inside the opencode package — the only files
|
||||
* that affect the OpenAPI output. Scoped to `src/server/` to avoid busting
|
||||
* the Gradle up-to-date check on unrelated file changes (dist/, node_modules/).
|
||||
*/
|
||||
@get:InputDirectory
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val serverSrcDir: DirectoryProperty
|
||||
@get:Input
|
||||
abstract val cliVersion: Property<String>
|
||||
|
||||
/**
|
||||
* Root of the `packages/opencode/` package — the working directory for bun.
|
||||
* Marked @Internal because it is not itself a Gradle input; only [serverSrcDir]
|
||||
* (a subdirectory) participates in up-to-date checking.
|
||||
*/
|
||||
@get:Internal
|
||||
abstract val opencodeDir: DirectoryProperty
|
||||
abstract val token: Property<String>
|
||||
|
||||
@get:Internal
|
||||
abstract val cacheDir: DirectoryProperty
|
||||
|
||||
/** Destination file for the generated openapi.json. */
|
||||
@get:OutputFile
|
||||
abstract val spec: RegularFileProperty
|
||||
|
||||
@@ -53,52 +51,206 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val kilo = resolve()
|
||||
val out = ByteArrayOutputStream()
|
||||
val err = ByteArrayOutputStream()
|
||||
val result = exec.exec {
|
||||
workingDir = opencodeDir.get().asFile
|
||||
commandLine(findBun(), "run", "--conditions=browser", "./src/index.ts", "generate")
|
||||
commandLine(kilo.absolutePath, "generate")
|
||||
standardOutput = out
|
||||
errorOutput = err
|
||||
isIgnoreExitValue = true
|
||||
}
|
||||
if (result.exitValue != 0) {
|
||||
throw GradleException(
|
||||
"bun dev generate failed with exit code ${result.exitValue}.\n" +
|
||||
err.toString(Charsets.UTF_8).take(2000)
|
||||
"kilo generate failed with exit code ${result.exitValue}.\n" +
|
||||
err.toString(Charsets.UTF_8).take(2000)
|
||||
)
|
||||
}
|
||||
val json = out.toString(Charsets.UTF_8)
|
||||
if (!json.trimStart().startsWith("{")) {
|
||||
throw GradleException(
|
||||
"bun dev generate did not produce JSON.\n" +
|
||||
"stdout: ${json.take(200)}\n" +
|
||||
"stderr: ${err.toString(Charsets.UTF_8).take(500)}"
|
||||
"kilo generate did not produce JSON.\n" +
|
||||
"stdout: ${json.take(200)}\n" +
|
||||
"stderr: ${err.toString(Charsets.UTF_8).take(500)}"
|
||||
)
|
||||
}
|
||||
spec.get().asFile.also { it.parentFile.mkdirs() }.writeText(json)
|
||||
}
|
||||
|
||||
private fun findBun(): String {
|
||||
val which = runCatching {
|
||||
ProcessBuilder("which", "bun")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
.inputStream.bufferedReader().readLine()?.trim()
|
||||
}.getOrNull()
|
||||
if (which != null && java.io.File(which).isFile) return which
|
||||
|
||||
val home = System.getProperty("user.home")
|
||||
val candidates = listOf(
|
||||
"$home/.bun/bin/bun",
|
||||
"/opt/homebrew/bin/bun",
|
||||
"/usr/local/bin/bun",
|
||||
"$home/.nvm/current/bin/bun",
|
||||
)
|
||||
for (path in candidates) {
|
||||
val f = java.io.File(path)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
private fun resolve(): File {
|
||||
val version = cliVersion.get()
|
||||
val platform = platform()
|
||||
val ext = if (platform.startsWith("linux-")) "tar.gz" else "zip"
|
||||
val dir = cacheDir.dir(version).map { it.dir(platform) }.get().asFile
|
||||
val exe = File(dir, "bin/${exe()}")
|
||||
val done = File(dir, ".complete")
|
||||
val cached = done.takeIf { it.isFile }?.readText()?.trim()
|
||||
val archive = File(dir, "kilo-$platform.$ext")
|
||||
if (exe.isFile && cached != null && cached.matches(DIGEST) && matches(archive, cached)) {
|
||||
if (!windows()) exe.setExecutable(true)
|
||||
return exe
|
||||
}
|
||||
return "bun"
|
||||
|
||||
val name = "kilo-$platform.$ext"
|
||||
val digest = asset(version, name)
|
||||
if (dir.exists() && !dir.deleteRecursively()) {
|
||||
throw GradleException("Failed to delete cached pinned Kilo CLI under ${dir.absolutePath}")
|
||||
}
|
||||
if (!dir.isDirectory && !dir.mkdirs()) {
|
||||
throw GradleException("Failed to create pinned Kilo CLI cache directory ${dir.absolutePath}")
|
||||
}
|
||||
download("https://github.com/Kilo-Org/kilocode/releases/download/v$version/kilo-$platform.$ext", archive)
|
||||
verify(archive, digest)
|
||||
extract(archive, dir)
|
||||
if (!exe.isFile) throw GradleException("Downloaded CLI archive did not contain bin/${exe()}")
|
||||
if (!windows()) exe.setExecutable(true)
|
||||
done.writeText("$digest\n")
|
||||
return exe
|
||||
}
|
||||
|
||||
private fun asset(version: String, name: String): String {
|
||||
val url = "$API/v$version"
|
||||
logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url")
|
||||
val conn = URI(url).toURL().openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 120_000
|
||||
conn.instanceFollowRedirects = true
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
token.getOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) {
|
||||
val info = rate(conn)
|
||||
val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } }
|
||||
.getOrNull()
|
||||
?.take(500)
|
||||
val detail = if (body.isNullOrBlank()) "" else ": $body"
|
||||
if (limited(conn, code)) {
|
||||
throw GradleException(
|
||||
"GitHub API rate limit exceeded while fetching pinned Kilo CLI release metadata ($info)$detail"
|
||||
)
|
||||
}
|
||||
throw GradleException("Failed to fetch pinned Kilo CLI release metadata: HTTP $code from $url ($info)$detail")
|
||||
}
|
||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
|
||||
?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name }
|
||||
?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull
|
||||
?: throw GradleException("Pinned Kilo CLI release $version did not include $name")
|
||||
if (!digest.matches(DIGEST)) throw GradleException("Pinned Kilo CLI release $version asset $name has invalid digest")
|
||||
return digest
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun rate(conn: HttpURLConnection): String {
|
||||
val reset = conn.getHeaderField("X-RateLimit-Reset")
|
||||
?.toLongOrNull()
|
||||
?.let { Instant.ofEpochSecond(it).toString() }
|
||||
return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " +
|
||||
"used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}"
|
||||
}
|
||||
|
||||
private fun limited(conn: HttpURLConnection, code: Int) =
|
||||
code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0")
|
||||
|
||||
private fun download(url: String, file: File) {
|
||||
logger.lifecycle("Downloading pinned Kilo CLI from $url")
|
||||
val conn = URI(url).toURL().openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 120_000
|
||||
conn.instanceFollowRedirects = true
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) throw GradleException("Failed to download pinned Kilo CLI: HTTP $code from $url")
|
||||
conn.inputStream.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(file: File, digest: String) {
|
||||
val actual = sum(file)
|
||||
if (actual == digest) return
|
||||
if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}")
|
||||
throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
|
||||
}
|
||||
|
||||
private fun matches(file: File, digest: String) = file.isFile && sum(file) == digest
|
||||
|
||||
private fun sum(file: File) = "sha256:${sha256(file)}"
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
md.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
private fun extract(file: File, dir: File) {
|
||||
if (file.name.endsWith(".zip")) {
|
||||
ZipInputStream(file.inputStream().buffered()).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
write(dir, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar ->
|
||||
while (true) {
|
||||
val entry = tar.nextEntry ?: break
|
||||
write(dir, entry.name, entry.isDirectory) { out -> tar.copyTo(out) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(dir: File, name: String, directory: Boolean, copy: (java.io.OutputStream) -> Unit) {
|
||||
val path = if (name.startsWith("bin/")) name else "bin/$name"
|
||||
val target = File(dir, path).canonicalFile
|
||||
val base = dir.canonicalFile
|
||||
if (target != base && !target.path.startsWith(base.path + File.separator)) {
|
||||
throw GradleException("Archive entry escapes target directory: $name")
|
||||
}
|
||||
if (directory) {
|
||||
target.mkdirs()
|
||||
return
|
||||
}
|
||||
target.parentFile.mkdirs()
|
||||
target.outputStream().use(copy)
|
||||
if (!windows() && (target.name == "kilo" || target.name == "bwrap")) target.setExecutable(true)
|
||||
}
|
||||
|
||||
private fun platform(): String {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
val name = when {
|
||||
os.contains("mac") || os.contains("darwin") -> "darwin"
|
||||
os.contains("linux") -> "linux"
|
||||
os.contains("windows") -> "windows"
|
||||
else -> throw GradleException("Unsupported OS: ${System.getProperty("os.name")}")
|
||||
}
|
||||
val arch = when (System.getProperty("os.arch").lowercase()) {
|
||||
"aarch64", "arm64" -> "arm64"
|
||||
"x86_64", "amd64" -> "x64"
|
||||
else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}")
|
||||
}
|
||||
return "$name-$arch"
|
||||
}
|
||||
|
||||
private fun exe() = if (windows()) "kilo.exe" else "kilo"
|
||||
|
||||
private fun windows() = System.getProperty("os.name").lowercase().contains("windows")
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.Internal
|
||||
import org.gradle.api.tasks.Optional
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.process.ExecOperations
|
||||
import org.gradle.work.DisableCachingByDefault
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Prepare the local-platform CLI binary for JetBrains backend runs.
|
||||
*
|
||||
* Runs `bun run build --prepare-cli` from the [root] project directory when
|
||||
* the expected current-platform binary is absent from [dir]. Skips
|
||||
* immediately when the binary already exists.
|
||||
*
|
||||
* Bun is found in the following priority order:
|
||||
* 1. Explicit `-Pkilo.bun.path=...` Gradle property (forwarded via [bunPath])
|
||||
* 2. `BUN_EXE` environment variable (absolute path to the bun executable)
|
||||
* 3. `BUN_INSTALL/bin/bun` (from `BUN_INSTALL` environment variable)
|
||||
* 4. Each entry on `PATH`
|
||||
* 5. Common install locations (`~/.bun/bin`, `/opt/homebrew/bin`, `/usr/local/bin`)
|
||||
*
|
||||
* After Bun finishes, the task verifies the expected binary exists so that Rosetta / architecture
|
||||
* mismatches surface with a clear error message.
|
||||
*/
|
||||
@DisableCachingByDefault(because = "Local developer bootstrap task that shells out to Bun")
|
||||
abstract class PrepareLocalCliTask : DefaultTask() {
|
||||
@get:Internal
|
||||
abstract val root: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val dir: DirectoryProperty
|
||||
|
||||
@get:Input
|
||||
@get:Optional
|
||||
abstract val bunPath: Property<String>
|
||||
|
||||
@get:Inject
|
||||
abstract val exec: ExecOperations
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val expected = binary()
|
||||
if (expected.exists()) {
|
||||
logger.lifecycle("CLI binary already exists at ${expected.absolutePath}")
|
||||
return
|
||||
}
|
||||
|
||||
val bun = findBun()
|
||||
logger.lifecycle("Preparing local CLI binary with ${bun.absolutePath}")
|
||||
val result = exec.exec {
|
||||
workingDir = root.get().asFile
|
||||
executable = bun.absolutePath
|
||||
args("run", "build", "--prepare-cli")
|
||||
isIgnoreExitValue = true
|
||||
}
|
||||
|
||||
if (result.exitValue != 0) {
|
||||
throw GradleException("bun run build --prepare-cli failed with exit code ${result.exitValue}")
|
||||
}
|
||||
|
||||
if (!expected.exists()) {
|
||||
throw GradleException(
|
||||
"Expected CLI binary was not created at ${expected.absolutePath}. " +
|
||||
"This can happen if Bun and Gradle are running under different architectures."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun binary(): File {
|
||||
return File(File(dir.get().asFile, platform()), exe())
|
||||
}
|
||||
|
||||
private fun platform(): String {
|
||||
return "${os()}-${arch()}"
|
||||
}
|
||||
|
||||
private fun os(): String {
|
||||
val name = System.getProperty("os.name").lowercase()
|
||||
if ("mac" in name || "darwin" in name) return "darwin"
|
||||
if ("linux" in name) return "linux"
|
||||
if ("windows" in name) return "windows"
|
||||
throw GradleException("Unsupported OS for JetBrains CLI binary: $name")
|
||||
}
|
||||
|
||||
private fun arch(): String {
|
||||
return when (val value = System.getProperty("os.arch").lowercase()) {
|
||||
"aarch64", "arm64" -> "arm64"
|
||||
"x86_64", "amd64" -> "x64"
|
||||
else -> throw GradleException("Unsupported architecture for JetBrains CLI binary: $value")
|
||||
}
|
||||
}
|
||||
|
||||
private fun exe(): String {
|
||||
if (os() == "windows") return "kilo.exe"
|
||||
return "kilo"
|
||||
}
|
||||
|
||||
private fun findBun(): File {
|
||||
val configured = bunPath.orNull?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (configured != null) return requireBun(File(configured))
|
||||
|
||||
val names = if (os() == "windows") listOf("bun.exe", "bun.cmd", "bun.bat") else listOf("bun")
|
||||
val candidates = mutableListOf<File>()
|
||||
|
||||
System.getenv("BUN_EXE")?.trim()?.takeIf { it.isNotEmpty() }?.let { candidates.add(File(it)) }
|
||||
System.getenv("BUN_INSTALL")?.trim()?.takeIf { it.isNotEmpty() }?.let { install ->
|
||||
candidates.addAll(names.map { File(File(install, "bin"), it) })
|
||||
}
|
||||
System.getenv("PATH")?.split(File.pathSeparator)?.forEach { entry ->
|
||||
candidates.addAll(names.map { File(entry, it) })
|
||||
}
|
||||
|
||||
val home = System.getProperty("user.home")
|
||||
candidates.addAll(names.map { File(File(home, ".bun/bin"), it) })
|
||||
candidates.addAll(names.map { File("/opt/homebrew/bin", it) })
|
||||
candidates.addAll(names.map { File("/usr/local/bin", it) })
|
||||
|
||||
return candidates.firstOrNull(::usable)
|
||||
?: throw GradleException("Could not find Bun. Install Bun or pass -Pkilo.bun.path=/absolute/path/to/bun.")
|
||||
}
|
||||
|
||||
private fun requireBun(file: File): File {
|
||||
if (usable(file)) return file
|
||||
throw GradleException("Configured Bun executable is not usable: ${file.absolutePath}")
|
||||
}
|
||||
|
||||
private fun usable(file: File): Boolean {
|
||||
if (!file.isFile) return false
|
||||
if (os() == "windows") return true
|
||||
return file.canExecute()
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,6 @@ tasks {
|
||||
|
||||
runIdeBackend {
|
||||
splitModeServerPort.set(splitPort)
|
||||
dependsOn(":backend:prepareLocalCli")
|
||||
dependsOn(":backend:processResources")
|
||||
}
|
||||
|
||||
@@ -231,17 +230,12 @@ tasks {
|
||||
|
||||
runIdeSplitMode {
|
||||
splitModeServerPort.set(splitPort)
|
||||
dependsOn(":backend:prepareLocalCli")
|
||||
dependsOn(":backend:processResources")
|
||||
}
|
||||
}
|
||||
|
||||
project(":backend").tasks.named("processResources") {
|
||||
mustRunAfter(":backend:prepareLocalCli")
|
||||
}
|
||||
|
||||
// Compile-only typecheck: verifies Kotlin compiles (including generated API client)
|
||||
// without running processResources, CLI binary prep, or buildPlugin.
|
||||
// without running buildPlugin.
|
||||
tasks.register("typecheck") {
|
||||
dependsOn(
|
||||
":shared:compileKotlin",
|
||||
@@ -252,12 +246,6 @@ tasks.register("typecheck") {
|
||||
)
|
||||
}
|
||||
|
||||
// CLI binaries must be present before packaging. Wire the check here (not in
|
||||
// :backend:processResources) so compile/test tasks work without CLI binaries.
|
||||
tasks.named("buildPlugin") {
|
||||
dependsOn(":backend:checkCli")
|
||||
}
|
||||
|
||||
tasks.named<JavaExec>("runIde") {
|
||||
dependsOn(":backend:processResources")
|
||||
jvmArgumentProviders += CommandLineArgumentProvider {
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
|
||||
class CoreInfoAction : AnAction(), DumbAware {
|
||||
override fun actionPerformed(e: AnActionEvent) = Unit
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val app = service<KiloAppService>()
|
||||
val info = app.core
|
||||
if (info == null) app.fetchCoreInfoAsync()
|
||||
e.presentation.text = info?.let {
|
||||
KiloBundle.message("action.Kilo.CoreInfo.text", it.version, it.platform)
|
||||
} ?: KiloBundle.message("action.Kilo.CoreInfo.loading")
|
||||
e.presentation.description = KiloBundle.message("action.Kilo.CoreInfo.description")
|
||||
e.presentation.isEnabled = false
|
||||
e.presentation.isVisible = true
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||
}
|
||||
+71
-20
@@ -27,7 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* App-level frontend service for Kilo CLI interaction.
|
||||
* App-level frontend service for Kilo Core interaction.
|
||||
*
|
||||
* Communicates with the backend via [KiloAppRpcApi]. All operations
|
||||
* are app-scoped — no project context is needed.
|
||||
@@ -47,10 +47,16 @@ class KiloAppService internal constructor(
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
/** CLI version string from the last successful health check, or null if unknown. */
|
||||
/** Core version and platform from the backend, or null if unknown. */
|
||||
@Volatile
|
||||
var version: String? = null
|
||||
private set
|
||||
private var info: CoreInfo? = null
|
||||
private val coreLock = Any()
|
||||
private val coreDone = mutableListOf<(CoreInfo?) -> Unit>()
|
||||
private var coreJob: Job? = null
|
||||
|
||||
val core: CoreInfo? get() = info
|
||||
|
||||
val version: String? get() = info?.version
|
||||
|
||||
internal val _state = MutableStateFlow(init)
|
||||
val state: StateFlow<KiloAppStateDto> = _state.asStateFlow()
|
||||
@@ -79,6 +85,9 @@ class KiloAppService internal constructor(
|
||||
}
|
||||
|
||||
private fun onState(state: KiloAppStateDto) {
|
||||
val version = state.downloadVersion
|
||||
val platform = state.downloadPlatform
|
||||
if (version != null && platform != null) info = CoreInfo(version, platform)
|
||||
_state.value = state
|
||||
if (state.status == KiloAppStatusDto.READY) refreshModelFavoritesAsync()
|
||||
}
|
||||
@@ -96,20 +105,20 @@ class KiloAppService internal constructor(
|
||||
call { retry() }
|
||||
}
|
||||
|
||||
/** Kill the CLI process and restart it. */
|
||||
/** Kill the Core process and restart it. */
|
||||
suspend fun restart() {
|
||||
LOG.info("restart: resetting state and sending RPC")
|
||||
started.set(false)
|
||||
version = null
|
||||
info = null
|
||||
call { restart() }
|
||||
LOG.info("restart: RPC returned — backend restart complete")
|
||||
}
|
||||
|
||||
/** Kill the CLI process, re-extract the binary, and restart. */
|
||||
/** Kill the Core process, re-download the binary, and restart. */
|
||||
suspend fun reinstall() {
|
||||
LOG.info("reinstall: resetting state and sending RPC")
|
||||
started.set(false)
|
||||
version = null
|
||||
info = null
|
||||
call { reinstall() }
|
||||
LOG.info("reinstall: RPC returned — backend reinstall complete")
|
||||
}
|
||||
@@ -131,18 +140,58 @@ class KiloAppService internal constructor(
|
||||
cs.launch { reinstall() }
|
||||
}
|
||||
|
||||
/** Fetch the CLI version and cache it. Call once after connection is established. */
|
||||
fun fetchVersionAsync() {
|
||||
cs.launch {
|
||||
LOG.info("fetchVersion: requesting health check")
|
||||
val dto = health()
|
||||
if (dto == null) {
|
||||
LOG.warn("fetchVersion: health check returned null — version not available")
|
||||
return@launch
|
||||
}
|
||||
version = dto.version
|
||||
LOG.info("fetchVersion: CLI version is ${dto.version}")
|
||||
suspend fun coreInfo(): CoreInfo? = try {
|
||||
val next = CoreInfo(
|
||||
version = call { cliVersion() },
|
||||
platform = call { cliPlatform() },
|
||||
)
|
||||
info = next
|
||||
next
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("core info failed", e)
|
||||
null
|
||||
}
|
||||
|
||||
/** Fetch the pinned Core version and platform and cache it. */
|
||||
fun fetchCoreInfoAsync(done: (CoreInfo?) -> Unit = {}) {
|
||||
val cached = info
|
||||
if (cached != null) {
|
||||
done(cached)
|
||||
return
|
||||
}
|
||||
|
||||
synchronized(coreLock) {
|
||||
val current = info
|
||||
if (current != null) {
|
||||
done(current)
|
||||
return
|
||||
}
|
||||
coreDone.add(done)
|
||||
if (coreJob != null) return
|
||||
coreJob = cs.launch {
|
||||
fetchCoreInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchCoreInfo() {
|
||||
LOG.info("fetchCoreInfo: requesting Core version and platform")
|
||||
val next = coreInfo()
|
||||
val list = synchronized(coreLock) {
|
||||
val items = coreDone.toList()
|
||||
coreDone.clear()
|
||||
coreJob = null
|
||||
items
|
||||
}
|
||||
list.forEach { it(next) }
|
||||
if (next != null) {
|
||||
LOG.info("fetchCoreInfo: Core version is ${next.version} (${next.platform})")
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the pinned Core version and cache it. */
|
||||
fun fetchVersionAsync(done: (String?) -> Unit = {}) {
|
||||
fetchCoreInfoAsync { done(it?.version) }
|
||||
}
|
||||
|
||||
fun refreshModelFavoritesAsync() {
|
||||
@@ -298,7 +347,7 @@ class KiloAppService internal constructor(
|
||||
fun watch(fn: (KiloAppStateDto) -> Unit): Job {
|
||||
return cs.launch {
|
||||
state.collect { next ->
|
||||
if (next.status == KiloAppStatusDto.READY) fetchVersionAsync()
|
||||
if (next.status == KiloAppStatusDto.READY) fetchCoreInfoAsync()
|
||||
fn(next)
|
||||
}
|
||||
}
|
||||
@@ -313,6 +362,8 @@ class KiloAppService internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
data class CoreInfo(val version: String, val platform: String)
|
||||
|
||||
private fun summary(patch: ConfigPatchDto): String {
|
||||
val values = patch.values.keys.sorted().joinToString(",").ifEmpty { "none" }
|
||||
return "values=$values agents=${patch.agents.size}"
|
||||
|
||||
+4
-5
@@ -201,11 +201,11 @@ class SessionUi(
|
||||
Disposer.register(this, popup)
|
||||
buildUi()
|
||||
Disposer.register(this, selection)
|
||||
applyStyle(style)
|
||||
scroll.show(body(controller.model.state))
|
||||
bindUi()
|
||||
bindStyle()
|
||||
bindMigration()
|
||||
applyStyle(style)
|
||||
onStateChanged(controller.model.state)
|
||||
loaded?.let(::finishOpen)
|
||||
}
|
||||
@@ -307,9 +307,7 @@ class SessionUi(
|
||||
|
||||
sessionContent = JPanel(BorderLayout())
|
||||
|
||||
blankBody = JPanel(BorderLayout()).apply {
|
||||
isOpaque = false
|
||||
}
|
||||
blankBody = JPanel(BorderLayout())
|
||||
|
||||
load = LoadingPanel()
|
||||
progressBody = load
|
||||
@@ -605,8 +603,8 @@ class SessionUi(
|
||||
}
|
||||
|
||||
private fun body(state: SessionState): JPanel {
|
||||
if (state is SessionState.Retry || state is SessionState.Offline) return progressBody
|
||||
if (controller.model.showSession) return messageBody
|
||||
if (state is SessionState.Retry || state is SessionState.Offline) return progressBody
|
||||
if (state is SessionState.Loading) return progressBody
|
||||
return blankBody
|
||||
}
|
||||
@@ -786,6 +784,7 @@ class SessionUi(
|
||||
editorTheme = style.editorScheme
|
||||
colorTheme = UIManager.getLookAndFeel()
|
||||
background = style.editorBackground
|
||||
root.background = style.editorBackground
|
||||
root.content.background = style.editorBackground
|
||||
sessionContent.background = style.editorBackground
|
||||
blankBody.background = style.editorBackground
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user