Merge remote-tracking branch 'origin/main' into feat/help-all-command

# Conflicts:
#	packages/opencode/src/index.ts
This commit is contained in:
Alex Alecu
2026-04-08 12:10:48 +03:00
44 changed files with 1314 additions and 1810 deletions
+2 -2
View File
@@ -18,7 +18,7 @@
- ⚡ Inline autocomplete suggestions
- 🤖 Latest AI models
- 🎁 API keys optional
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.2
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4
## Quick Links
@@ -38,7 +38,7 @@
## Get Started in Visual Studio Code
1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code).
2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly.
2. Create your account to access 500+ cutting-edge AI models including Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 – with transparent pricing that matches provider rates exactly.
3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action:
<a href="https://youtu.be/pqGfYXgrhig"><img src="https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg" alt="Watch the video" width="640" height="360"></a>
+55
View File
@@ -126,6 +126,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private cachedMcpStatusMessage: unknown = null
/** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */
private pending = 0
private configWarningsShown = false
/** Cached notificationsLoaded payload */
private cachedNotificationsMessage: unknown = null
private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = []
@@ -1055,6 +1056,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({ type: "connectionState", state })
if (state === "connected") {
// Fire config warnings independently so a failure in the
// sequential await chain doesn't prevent warnings from being shown
void this.checkConfigWarnings("state")
try {
// Profile fetch is best-effort — returns 401 when user isn't logged into gateway.
const sdkClient = this.client
@@ -1130,6 +1134,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
this.postMessage({ type: "connectionState", state: this.connectionState })
// connect() can resolve after SSE reaches "connected" but before this
// provider subscribes to onStateChange(). In that case the initial
// connected callback is missed, so run the warning check here too.
if (this.connectionState === "connected") {
void this.checkConfigWarnings("init")
}
await this.syncWebviewState("initializeConnection")
await this.flushPendingSessionRefresh("initializeConnection")
@@ -1965,6 +1977,49 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
/**
* Fetch config warnings from the server and display a single consolidated
* VS Code warning with a "Show Details" action button.
* Only shown once per provider lifecycle (flag resets on dispose/re-create, not on SSE reconnect).
*/
private async checkConfigWarnings(from: string): Promise<void> {
if (this.configWarningsShown) {
console.log("[Kilo New] KiloProvider: config warnings already shown", { from })
return
}
if (!this.client) {
console.log("[Kilo New] KiloProvider: config warnings skipped (no client)", { from })
return
}
try {
const dir = this.getWorkspaceDirectory()
console.log("[Kilo New] KiloProvider: checking config warnings", { from, dir })
const result = await this.client.config.warnings({ directory: dir })
const list = result?.data ?? []
console.log("[Kilo New] KiloProvider: config warnings fetched", { from, count: list.length })
if (list.length === 0) return
this.configWarningsShown = true
const first = list[0]!
const summary = list.length === 1 ? first.message : `${first.message} (and ${list.length - 1} more)`
console.warn("[Kilo New] KiloProvider: showing config warnings", { from, count: list.length, path: first.path })
const action = await vscode.window.showWarningMessage(`Config: ${summary}`, "Show Details")
if (action === "Show Details") {
const lines = list.map((w) => {
const base = `${w.path}\n ${w.message}`
return w.detail ? `${base}\n ${w.detail}` : base
})
const channel = vscode.window.createOutputChannel("Kilo Config Warnings")
channel.clear()
channel.appendLine(lines.join("\n\n"))
channel.show()
}
} catch (err) {
console.warn("[Kilo New] KiloProvider: checkConfigWarnings failed:", { from, err })
}
}
/**
* Fetch Kilo news/notifications and send to webview.
* Uses the cached message pattern so the webview gets data immediately on refresh.
@@ -695,11 +695,11 @@ export class AgentManagerProvider implements Disposable {
this.pushState()
// Disk removal after state is clean — pollers no longer reference this worktree.
try {
await manager.removeWorktree(worktree.path, worktree.branch)
await manager.removeWorktree(worktree.path, worktree.originalBranch ?? worktree.branch)
} catch (error) {
this.log(`Failed to remove worktree from disk: ${error}`)
}
this.log(`Deleted worktree ${worktreeId} (${worktree.branch})`)
this.log(`Deleted worktree ${worktreeId} (${worktree.originalBranch ?? worktree.branch})`)
return null
}
@@ -1444,12 +1444,20 @@ export class AgentManagerProvider implements Disposable {
const entries = result.worktrees.filter((item) => ids.has(item.worktreeId))
if (entries.length === 0) return
// Sync branches from git worktree list (no extra git calls)
let branchChanged = false
for (const entry of entries) {
if (entry.branch && state.updateWorktreeBranch(entry.worktreeId, entry.branch)) {
branchChanged = true
}
}
const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId))
const changed =
const staleChanged =
next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId))
this.staleWorktreeIds = next
if (changed) {
if (staleChanged || branchChanged) {
this.pushState()
}
}
@@ -130,14 +130,14 @@ export class GitOps {
}
/** Return the set of worktree paths for the repo, excluding bare entries. */
async listWorktreePaths(cwd: string): Promise<Set<string>> {
async listWorktreePaths(cwd: string): Promise<Map<string, string>> {
const raw = await this.raw(["worktree", "list", "--porcelain"], cwd)
const paths = new Set<string>()
const result = new Map<string, string>()
for (const entry of parseWorktreeList(raw)) {
if (entry.bare) continue
paths.add(normalizePath(entry.path))
result.set(normalizePath(entry.path), entry.branch)
}
return paths
return result
}
/**
@@ -26,6 +26,8 @@ export interface LocalStats {
export interface WorktreePresence {
worktreeId: string
missing: boolean
/** Current branch from `git worktree list`, if available. */
branch?: string
}
export interface WorktreePresenceResult {
@@ -231,7 +233,8 @@ export class GitStatsPoller {
() => false,
)
const missing = !exists || !tracked.has(normalized)
return { worktreeId: wt.id, missing }
const branch = tracked.get(normalized)
return { worktreeId: wt.id, missing, branch }
}),
)
@@ -32,6 +32,9 @@ export interface Worktree {
prUrl?: string
/** Cached PR state for correct badge color on reload (open/merged/closed/draft). */
prState?: string
/** Original branch created with the worktree, used for cleanup on deletion.
* Set automatically when `branch` is updated via live sync. */
originalBranch?: string
}
/**
@@ -169,6 +172,16 @@ export class WorktreeStateManager {
return wt
}
updateWorktreeBranch(id: string, branch: string): boolean {
const wt = this.worktrees.get(id)
if (!wt || wt.branch === branch) return false
if (!wt.originalBranch) wt.originalBranch = wt.branch
this.log(`Updated worktree ${id} branch: ${wt.branch} → ${branch}`)
wt.branch = branch
void this.save()
return true
}
updateWorktreeLabel(id: string, label: string): void {
const wt = this.worktrees.get(id)
if (!wt) return
@@ -3,7 +3,7 @@ import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { GitStatsPoller } from "../../src/agent-manager/GitStatsPoller"
import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller"
import { GitOps } from "../../src/agent-manager/GitOps"
import type { Worktree } from "../../src/agent-manager/WorktreeStateManager"
@@ -128,7 +128,7 @@ describe("GitStatsPoller", () => {
const wtPath = path.join(root, "wt-a")
fs.mkdirSync(wtPath, { recursive: true })
const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = []
const presence: WorktreePresenceResult[] = []
const poller = new GitStatsPoller({
getWorktrees: () => [{ ...worktree("a"), path: wtPath }],
@@ -154,7 +154,10 @@ describe("GitStatsPoller", () => {
poller.stop()
fs.rmSync(root, { recursive: true, force: true })
expect(presence[0]).toEqual({ worktrees: [{ worktreeId: "a", missing: false }], degraded: false })
expect(presence[0]).toEqual({
worktrees: [{ worktreeId: "a", missing: false, branch: "branch-a" }],
degraded: false,
})
})
it("emits degraded probe when git worktree listing fails", async () => {
@@ -162,7 +165,7 @@ describe("GitStatsPoller", () => {
const wtPath = path.join(root, "wt-a")
fs.mkdirSync(wtPath, { recursive: true })
const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = []
const presence: WorktreePresenceResult[] = []
const poller = new GitStatsPoller({
getWorktrees: () => [{ ...worktree("a"), path: wtPath }],
@@ -199,7 +202,7 @@ describe("GitStatsPoller", () => {
const calls: string[] = []
const emitted: Array<Array<{ worktreeId: string; additions: number; deletions: number; commits: number }>> = []
const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = []
const presence: WorktreePresenceResult[] = []
const client = {
worktree: {
@@ -240,8 +243,8 @@ describe("GitStatsPoller", () => {
expect(calls.some((cwd) => cwd === wtBPath)).toBe(false)
expect(presence[0]).toEqual({
worktrees: [
{ worktreeId: "a", missing: false },
{ worktreeId: "b", missing: true },
{ worktreeId: "a", missing: false, branch: "branch-a" },
{ worktreeId: "b", missing: true, branch: undefined },
],
degraded: false,
})
@@ -307,6 +307,11 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
sendAllToChat()
}
const handleExpandAll = () => {
const allOpen = open().length === props.diffs.length
setOpen(allOpen ? [] : props.diffs.map((d) => d.file))
}
const totals = createMemo(() => ({
files: props.diffs.length,
additions: props.diffs.reduce((sum, diff) => sum + diff.additions, 0),
@@ -354,6 +359,28 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
</Show>
</div>
<div class="am-diff-header-actions">
<Show when={props.diffs.length > 0}>
<Tooltip
value={
open().length === props.diffs.length
? t("ui.sessionReview.collapseAll")
: t("ui.sessionReview.expandAll")
}
placement="bottom"
>
<IconButton
icon="chevron-grabber-vertical"
size="small"
variant="ghost"
label={
open().length === props.diffs.length
? t("ui.sessionReview.collapseAll")
: t("ui.sessionReview.expandAll")
}
onClick={handleExpandAll}
/>
</Tooltip>
</Show>
<Show when={props.onExpand}>
<Tooltip value={t("command.review.toggle")} placement="bottom">
<IconButton
@@ -62,12 +62,16 @@ export const KiloNotifications: Component = () => {
return true
})
const MAX_NAME = 30
const suggestedName = createMemo(() => {
const suggestion = suggestedModel()
if (!suggestion) return undefined
const model = provider.findModel(suggestion)
if (!model?.name) return undefined
return stripSubProviderPrefix(model.name)
const name = stripSubProviderPrefix(model.name)
if (name.length > MAX_NAME) return undefined
return name
})
const handleTryModel = () => {
@@ -98,7 +102,9 @@ export const KiloNotifications: Component = () => {
<div class="kilo-notifications-cta-group">
<Show when={canSwitchModel()}>
<button class="kilo-notifications-action-btn" onClick={handleTryModel}>
{language.t("notifications.action.tryModel", { model: suggestedName() ?? "" })}
{suggestedName()
? language.t("notifications.action.tryModel", { model: suggestedName()! })
: language.t("notifications.action.tryModelGeneric")}
</button>
</Show>
<Show when={current()?.action}>
@@ -244,7 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
window.addEventListener("compactSession", onCompact)
onCleanup(() => window.removeEventListener("compactSession", onCompact))
const isBusy = () => session.status() === "busy"
const isBusy = () => session.status() !== "idle"
const isDisabled = () => !server.isConnected()
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
const canSend = () => hasInput() && !isDisabled() && !props.blocked?.()
@@ -1004,7 +1004,15 @@ const AgentBehaviourTab: Component = () => {
>
{path}
</span>
<IconButton size="small" variant="ghost" icon="close" onClick={() => removeInstruction(index())} />
<div style={{ display: "flex", "align-items": "center", gap: "4px" }}>
<IconButton
size="small"
variant="ghost"
icon="pencil-line"
onClick={() => vscode.postMessage({ type: "openFile", filePath: path })}
/>
<IconButton size="small" variant="ghost" icon="close" onClick={() => removeInstruction(index())} />
</div>
</div>
)}
</For>
+1
View File
@@ -1334,4 +1334,5 @@ export const dict = {
"notifications.action.next": "التالي",
"notifications.action.close": "إغلاق",
"notifications.action.tryModel": "جرّب {{model}}",
"notifications.action.tryModelGeneric": "جرّب النموذج",
}
+1
View File
@@ -1363,4 +1363,5 @@ export const dict = {
"notifications.action.next": "Próximo",
"notifications.action.close": "Fechar",
"notifications.action.tryModel": "Experimentar {{model}}",
"notifications.action.tryModelGeneric": "Experimentar modelo",
}
+1
View File
@@ -1359,4 +1359,5 @@ export const dict = {
"notifications.action.next": "Sljedeći",
"notifications.action.close": "Zatvori",
"notifications.action.tryModel": "Probaj {{model}}",
"notifications.action.tryModelGeneric": "Probaj model",
}
+1
View File
@@ -1350,4 +1350,5 @@ export const dict = {
"notifications.action.next": "Næste",
"notifications.action.close": "Luk",
"notifications.action.tryModel": "Prøv {{model}}",
"notifications.action.tryModelGeneric": "Prøv model",
}
+1
View File
@@ -1377,4 +1377,5 @@ export const dict = {
"notifications.action.next": "Weiter",
"notifications.action.close": "Schließen",
"notifications.action.tryModel": "{{model}} ausprobieren",
"notifications.action.tryModelGeneric": "Modell ausprobieren",
} satisfies Partial<Record<Keys, string>>
@@ -1175,7 +1175,8 @@ export const dict = {
"settings.agentBehaviour.editMode.prompt": "System Prompt",
"settings.agentBehaviour.editMode.save": "Done",
"settings.agentBehaviour.editMode.back": "Back to list",
"settings.agentBehaviour.editMode.native": "Built-in mode (read-only definition)",
"settings.agentBehaviour.editMode.native":
"This is a built-in mode. Its base definition cannot be changed, but you can configure overrides below.",
"settings.agentBehaviour.editMode.promptOverride": "Custom prompt override for this built-in mode",
"settings.autoApprove.description":
@@ -1360,4 +1361,5 @@ export const dict = {
"notifications.action.next": "Next",
"notifications.action.close": "Close",
"notifications.action.tryModel": "Try {{model}}",
"notifications.action.tryModelGeneric": "Try Model",
}
+1
View File
@@ -1366,4 +1366,5 @@ export const dict = {
"notifications.action.next": "Siguiente",
"notifications.action.close": "Cerrar",
"notifications.action.tryModel": "Probar {{model}}",
"notifications.action.tryModelGeneric": "Probar modelo",
}
+1
View File
@@ -1382,4 +1382,5 @@ export const dict = {
"notifications.action.next": "Suivant",
"notifications.action.close": "Fermer",
"notifications.action.tryModel": "Essayer {{model}}",
"notifications.action.tryModelGeneric": "Essayer le modèle",
}
+1
View File
@@ -1349,4 +1349,5 @@ export const dict = {
"notifications.action.next": "次へ",
"notifications.action.close": "閉じる",
"notifications.action.tryModel": "{{model}}を試す",
"notifications.action.tryModelGeneric": "モデルを試す",
}
+1
View File
@@ -1337,4 +1337,5 @@ export const dict = {
"notifications.action.next": "다음",
"notifications.action.close": "닫기",
"notifications.action.tryModel": "{{model}} 시도",
"notifications.action.tryModelGeneric": "모델 시도",
}
+3 -1
View File
@@ -1355,6 +1355,7 @@ export const dict = {
"notifications.action.next": "Volgende",
"notifications.action.close": "Sluiten",
"notifications.action.tryModel": "Probeer {{model}}",
"notifications.action.tryModelGeneric": "Probeer model",
// Missing translations - English fallbacks until translated
"settings.agentBehaviour.createMode": "Nieuwe modus aanmaken",
"settings.agentBehaviour.createMode.button": "Aanmaken",
@@ -1377,7 +1378,8 @@ export const dict = {
"settings.agentBehaviour.editMode": "Modus bewerken",
"settings.agentBehaviour.editMode.back": "Terug naar lijst",
"settings.agentBehaviour.editMode.description": "Beschrijving",
"settings.agentBehaviour.editMode.native": "Ingebouwde modus (alleen-lezen definitie)",
"settings.agentBehaviour.editMode.native":
"Dit is een ingebouwde modus. De basisdefinitie kan niet worden gewijzigd, maar u kunt hieronder overrides configureren.",
"settings.agentBehaviour.editMode.prompt": "Systeemprompt",
"settings.agentBehaviour.editMode.promptOverride": "Aangepaste systeemprompt voor deze ingebouwde modus",
"settings.agentBehaviour.editMode.save": "Klaar",
+1
View File
@@ -1348,4 +1348,5 @@ export const dict = {
"notifications.action.next": "Neste",
"notifications.action.close": "Lukk",
"notifications.action.tryModel": "Prøv {{model}}",
"notifications.action.tryModelGeneric": "Prøv modell",
} satisfies Partial<Record<Keys, string>>
+1
View File
@@ -1358,4 +1358,5 @@ export const dict = {
"notifications.action.next": "Następny",
"notifications.action.close": "Zamknij",
"notifications.action.tryModel": "Wypróbuj {{model}}",
"notifications.action.tryModelGeneric": "Wypróbuj model",
}
+1
View File
@@ -1357,4 +1357,5 @@ export const dict = {
"notifications.action.next": "Далее",
"notifications.action.close": "Закрыть",
"notifications.action.tryModel": "Попробовать {{model}}",
"notifications.action.tryModelGeneric": "Попробовать модель",
}
+1
View File
@@ -1333,4 +1333,5 @@ export const dict = {
"notifications.action.next": "ถัดไป",
"notifications.action.close": "ปิด",
"notifications.action.tryModel": "ลองใช้ {{model}}",
"notifications.action.tryModelGeneric": "ลองใช้โมเดล",
}
+3 -1
View File
@@ -1347,6 +1347,7 @@ export const dict = {
"notifications.action.next": "Sonraki",
"notifications.action.close": "Kapat",
"notifications.action.tryModel": "Dene {{model}}",
"notifications.action.tryModelGeneric": "Modeli Dene",
// Missing translations - English fallbacks until translated
"profile.switchingAccount": "Hesap değiştiriliyor…",
"settings.agentBehaviour.createMode": "Yeni Mod Oluştur",
@@ -1370,7 +1371,8 @@ export const dict = {
"settings.agentBehaviour.editMode": "Modu Düzenle",
"settings.agentBehaviour.editMode.back": "Listeye dön",
"settings.agentBehaviour.editMode.description": "Açıklama",
"settings.agentBehaviour.editMode.native": "Yerleşik mod (salt okunur tanım)",
"settings.agentBehaviour.editMode.native":
"Bu yerleşik bir moddur. Temel tanımı değiştirilemez, ancak aşağıdan geçersiz kılmaları yapılandırabilirsiniz.",
"settings.agentBehaviour.editMode.prompt": "Sistem İstemi",
"settings.agentBehaviour.editMode.promptOverride": "Bu yerleşik mod için özel sistem istemi geçersiz kılma",
"settings.agentBehaviour.editMode.save": "Tamam",
+3 -1
View File
@@ -1348,6 +1348,7 @@ export const dict = {
"notifications.action.next": "Далі",
"notifications.action.close": "Закрити",
"notifications.action.tryModel": "Спробувати {{model}}",
"notifications.action.tryModelGeneric": "Спробувати модель",
// Missing translations - English fallbacks until translated
"profile.switchingAccount": "Перемикання акаунту…",
"settings.agentBehaviour.createMode": "Створити новий режим",
@@ -1371,7 +1372,8 @@ export const dict = {
"settings.agentBehaviour.editMode": "Редагувати режим",
"settings.agentBehaviour.editMode.back": "Назад до списку",
"settings.agentBehaviour.editMode.description": "Опис",
"settings.agentBehaviour.editMode.native": "Вбудований режим (визначення лише для читання)",
"settings.agentBehaviour.editMode.native":
"Це вбудований режим. Його базове визначення неможливо змінити, але ви можете налаштувати перевизначення нижче.",
"settings.agentBehaviour.editMode.prompt": "Системний запит",
"settings.agentBehaviour.editMode.promptOverride":
"Власне перевизначення системного запиту для цього вбудованого режиму",
+1
View File
@@ -1308,4 +1308,5 @@ export const dict = {
"notifications.action.next": "下一个",
"notifications.action.close": "关闭",
"notifications.action.tryModel": "尝试 {{model}}",
"notifications.action.tryModelGeneric": "尝试模型",
} satisfies Partial<Record<Keys, string>>
+1
View File
@@ -1311,4 +1311,5 @@ export const dict = {
"notifications.action.next": "下一個",
"notifications.action.close": "關閉",
"notifications.action.tryModel": "嘗試 {{model}}",
"notifications.action.tryModelGeneric": "嘗試模型",
} satisfies Partial<Record<Keys, string>>
+40
View File
@@ -0,0 +1,40 @@
// kilocode_change - new file
import { EOL } from "os"
import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
import { UI } from "../ui"
export const ConfigCommand = cmd({
command: "config",
describe: "configuration tools",
builder: (yargs) =>
yargs
.command({
command: "check",
describe: "check configuration for warnings and errors",
async handler() {
await bootstrap(process.cwd(), async () => {
const list = await Config.warnings()
if (list.length === 0) {
process.stdout.write("No config warnings." + EOL)
return
}
const S = UI.Style
for (const warning of list) {
process.stderr.write(S.TEXT_WARNING_BOLD + warning.path + S.TEXT_NORMAL + EOL)
process.stderr.write(" " + warning.message + EOL)
if (warning.detail) {
for (const line of warning.detail.split("\n")) {
process.stderr.write(" " + S.TEXT_DIM + line + S.TEXT_NORMAL + EOL)
}
}
process.stderr.write(EOL)
}
process.exitCode = 1
})
},
})
.demandCommand(),
async handler() {},
})
@@ -636,6 +636,7 @@ export function Prompt(props: PromptProps) {
})
.catch(() => {})
}
toast.dismiss() // kilocode_change - dismiss persistent config warning on first submit
history.append({
...store.prompt,
mode: currentMode,
@@ -29,6 +29,7 @@ import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@kilocode/sdk"
import type { Workspace } from "@kilocode/sdk/v2"
import { useToast } from "../ui/toast" // kilocode_change
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -106,6 +107,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const sdk = useSDK()
const toast = useToast() // kilocode_change
const fullSyncedSessions = new Set<string>() // kilocode_change
@@ -464,6 +466,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
syncWorkspaces(),
// kilocode_change start - show config warnings as persistent toast
sdk.client.config
.warnings()
.then((x) => {
const list = x.data ?? []
if (list.length === 0) return
const first = list[0]
const suffix = list.length > 1 ? ` (and ${list.length - 1} more)` : ""
toast.show({
title: "Config Warning",
message: first.message + suffix,
variant: "warning",
duration: 0,
})
})
.catch(() => {}),
// kilocode_change end
]).then(() => {
setStore("status", "complete")
})
+14 -3
View File
@@ -60,10 +60,21 @@ function init() {
const { duration, ...currentToast } = parsedOptions
setStore("currentToast", currentToast)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
setStore("currentToast", null)
}, duration).unref()
// kilocode_change start
timeoutHandle = null
if (duration && duration > 0) {
timeoutHandle = setTimeout(() => {
setStore("currentToast", null)
timeoutHandle = null
}, duration).unref()
}
},
dismiss() {
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = null
setStore("currentToast", null)
},
// kilocode_change end
error: (err: any) => {
if (err instanceof Error)
return toast.show({
+207 -50
View File
@@ -52,6 +52,15 @@ export namespace Config {
const log = Log.create({ service: "config" })
// kilocode_change start
export const Warning = z.object({
path: z.string(),
message: z.string(),
detail: z.string().optional(),
})
export type Warning = z.infer<typeof Warning>
// kilocode_change end
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
// These settings override all user and project settings
function systemManagedConfigDir(): string {
@@ -85,6 +94,18 @@ export namespace Config {
// kilocode_change start — capture init so resetState() can invalidate the cache entry
const stateInit = async () => {
// kilocode_change end
// kilocode_change start
const warnings: Warning[] = []
const caught = (err: unknown, source: string) => {
const w = toWarning(err)
if (w) {
warnings.push(w)
log.warn("skipped config due to error", { source, err })
return
}
throw err
}
// kilocode_change end
const auth = await Auth.all()
@@ -200,24 +221,34 @@ export namespace Config {
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = await fetch(`${url}/.well-known/opencode`)
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
// kilocode_change start
const source = `${url}/.well-known/opencode`
try {
process.env[value.key] = value.token
log.debug("fetching remote config", { url: source })
const response = await fetch(source)
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (await response.json()) as any
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json"
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
}),
)
log.debug("loaded remote config from well-known", { url })
} catch (err) {
const w = toWarning(err)
if (w) warnings.push(w)
else warnings.push({ path: source, message: err instanceof Error ? err.message : String(err) })
log.warn("skipped remote config due to error", { url, err })
// kilocode_change end
}
const wellknown = (await response.json()) as any
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" // kilocode_change
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(remoteConfig), {
dir: path.dirname(`${url}/.well-known/opencode`),
source: `${url}/.well-known/opencode`,
}),
)
log.debug("loaded remote config from well-known", { url })
}
}
@@ -226,21 +257,37 @@ export namespace Config {
}
// Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global())
// kilocode_change start
try {
result = mergeConfigConcatArrays(result, await global())
} catch (err) {
caught(err, "global config")
}
// kilocode_change end
// Custom config path overrides global config.
if (Flag.KILO_CONFIG) {
result = mergeConfigConcatArrays(result, await loadFile(Flag.KILO_CONFIG))
log.debug("loaded custom config", { path: Flag.KILO_CONFIG })
// kilocode_change start
try {
result = mergeConfigConcatArrays(result, await loadFile(Flag.KILO_CONFIG))
log.debug("loaded custom config", { path: Flag.KILO_CONFIG })
} catch (err) {
caught(err, Flag.KILO_CONFIG)
}
// kilocode_change end
}
// Project config overrides global and remote config.
if (!Flag.KILO_DISABLE_PROJECT_CONFIG) {
// kilocode_change start
for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) {
// kilocode_change end
result = mergeConfigConcatArrays(result, await loadFile(file))
try {
result = mergeConfigConcatArrays(result, await loadFile(file))
} catch (err) {
caught(err, file)
}
}
// kilocode_change end
}
result.agent = result.agent || {}
@@ -265,14 +312,18 @@ export namespace Config {
dir === Flag.KILO_CONFIG_DIR
) {
for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) {
// kilocode_change end
log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
try {
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
} catch (err) {
caught(err, path.join(dir, file))
}
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
// kilocode_change end
}
deps.push(
@@ -282,22 +333,34 @@ export namespace Config {
}),
)
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir))
result.agent = mergeDeep(result.agent, await loadMode(dir))
result.plugin.push(...(await loadPlugin(dir)))
// kilocode_change start
try {
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir, warnings))
result.agent = mergeDeep(result.agent, await loadAgent(dir, warnings))
result.agent = mergeDeep(result.agent, await loadMode(dir, warnings))
result.plugin.push(...(await loadPlugin(dir)))
} catch (err: unknown) {
log.error("failed to load config directory", { dir, err })
}
// kilocode_change end
}
// Inline config content overrides all non-managed config sources.
if (process.env.KILO_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(
result,
await load(process.env.KILO_CONFIG_CONTENT, {
dir: Instance.directory,
source: "KILO_CONFIG_CONTENT",
}),
)
log.debug("loaded custom config from KILO_CONFIG_CONTENT")
// kilocode_change start
try {
result = mergeConfigConcatArrays(
result,
await load(process.env.KILO_CONFIG_CONTENT, {
dir: Instance.directory,
source: "KILO_CONFIG_CONTENT",
}),
)
log.debug("loaded custom config from KILO_CONFIG_CONTENT")
} catch (err) {
caught(err, "KILO_CONFIG_CONTENT")
}
// kilocode_change end
}
// Load managed config files last (highest priority) - enterprise admin-controlled
@@ -307,9 +370,9 @@ export namespace Config {
if (existsSync(managedDir)) {
// kilocode_change start
for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) {
// kilocode_change end
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
}
// kilocode_change end
}
// Migrate deprecated mode field to agent field
@@ -361,6 +424,7 @@ export namespace Config {
config: result,
directories,
deps,
warnings, // kilocode_change
}
}
// kilocode_change start — create state from named init so resetState() can invalidate it
@@ -463,7 +527,64 @@ export namespace Config {
return ext.length ? file.slice(0, -ext.length) : file
}
async function loadCommand(dir: string) {
// kilocode_change start
function toWarning(err: unknown): Warning | undefined {
if (ConfigPaths.JsonError.isInstance(err))
return {
path: err.data.path,
message: `Config file at ${err.data.path} is not valid JSON(C)`,
detail: err.data.message || undefined,
}
if (ConfigPaths.InvalidError.isInstance(err)) {
const text = err.data.issues ? detail(err.data.issues) : err.data.message
return {
path: err.data.path,
message: text
? `Configuration is invalid at ${err.data.path}: ${text}`
: `Configuration is invalid at ${err.data.path}`,
}
}
return undefined
}
function detail(issues: z.core.$ZodIssue[]) {
return issues
.map((issue) => {
const loc = issue.path.map(String).join(".")
if (!loc) return issue.message
return `${loc}: ${issue.message}`
})
.join("\n")
}
async function invalid(
kind: "agent" | "command",
item: string,
issues: z.core.$ZodIssue[],
cause: Error,
warnings?: Warning[],
) {
const text = detail(issues)
const message = text ? `Config file at ${item} is invalid: ${text}` : `Config file at ${item} is invalid`
const err = new InvalidError({ path: item, issues }, { cause })
if (warnings) warnings.push({ path: item, message, detail: text || undefined })
try {
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
} catch (e) {
log.warn("could not publish session error", { message, err: e })
}
if (kind === "command") {
log.error("failed to load command", { command: item, err, message })
return
}
log.error("failed to load agent", { agent: item, err, message })
}
// kilocode_change end
// kilocode_change start
async function loadCommand(dir: string, warnings?: Warning[]) {
// kilocode_change end
const result: Record<string, Command> = {}
for (const item of await Glob.scan("{command,commands}/**/*.md", {
cwd: dir,
@@ -475,10 +596,17 @@ export namespace Config {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse command ${item}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
// kilocode_change start
if (warnings) warnings.push({ path: item, message })
try {
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
} catch (e) {
log.warn("could not publish session error", { message, err: e })
}
log.error("failed to load command", { command: item, err })
return undefined
// kilocode_change end
})
if (!md) continue
@@ -505,12 +633,16 @@ export namespace Config {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
// kilocode_change start
await invalid("command", item, parsed.error.issues, parsed.error, warnings)
// kilocode_change end
}
return result
}
async function loadAgent(dir: string) {
// kilocode_change start
async function loadAgent(dir: string, warnings?: Warning[]) {
// kilocode_change end
const result: Record<string, Agent> = {}
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
@@ -523,10 +655,17 @@ export namespace Config {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse agent ${item}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
// kilocode_change start
if (warnings) warnings.push({ path: item, message })
try {
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
} catch (e) {
log.warn("could not publish session error", { message, err: e })
}
log.error("failed to load agent", { agent: item, err })
return undefined
// kilocode_change end
})
if (!md) continue
@@ -555,12 +694,16 @@ export namespace Config {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
// kilocode_change start
await invalid("agent", item, parsed.error.issues, parsed.error, warnings)
// kilocode_change end
}
return result
}
async function loadMode(dir: string) {
// kilocode_change start
async function loadMode(dir: string, warnings?: Warning[]) {
// kilocode_change end
const result: Record<string, Agent> = {}
for (const item of await Glob.scan("{mode,modes}/*.md", {
cwd: dir,
@@ -572,10 +715,17 @@ export namespace Config {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse mode ${item}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
// kilocode_change start
if (warnings) warnings.push({ path: item, message })
try {
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
} catch (e) {
log.warn("could not publish session error", { message, err: e })
}
log.error("failed to load mode", { mode: item, err })
return undefined
// kilocode_change end
})
if (!md) continue
@@ -592,6 +742,9 @@ export namespace Config {
}
continue
}
// kilocode_change start
await invalid("agent", item, parsed.error.issues, parsed.error, warnings)
// kilocode_change end
}
return result
}
@@ -1497,6 +1650,12 @@ export namespace Config {
return state().then((x) => x.config)
}
// kilocode_change start
export async function warnings() {
return state().then((x) => x.warnings)
}
// kilocode_change end
export async function getGlobal() {
return global()
}
@@ -1706,5 +1865,3 @@ export namespace Config {
return state().then((x) => x.directories)
}
}
Filesystem.write
Filesystem.write
+2
View File
@@ -15,6 +15,7 @@ import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve"
import { Filesystem } from "./util/filesystem"
import { ConfigCommand as ConfigCLICommand } from "./cli/cmd/config" // kilocode_change
import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp"
@@ -197,6 +198,7 @@ let cli = yargs(hideBin(process.argv))
.command(RemoteCommand) // kilocode_change
.command(DbCommand)
.command(HelpCommand) // kilocode_change
.command(ConfigCLICommand) // kilocode_change
if (Installation.isLocal()) {
cli = cli.command(WorkspaceServeCommand)
@@ -15,7 +15,7 @@ export const BUILTIN_SKILLS: BuiltinSkill[] = [
{
name: "kilo-config",
description:
"Guide for configuring Kilo CLI: commands, agents, MCP servers, skills, permissions, instructions, plugins, providers, all kilo.json fields, and TUI settings (themes, appearance, keybinds, ctrl+p commands). Use when the user asks about configuring, customizing, or changing settings in Kilo.",
"Guide for configuring Kilo CLI and locating config, command, agent, and skill paths (global, project, legacy), plus MCP servers, permissions, instructions, plugins, providers, kilo.json fields, and TUI settings (themes, appearance, keybinds, ctrl+p commands). Use when the user asks about configuring Kilo, where it loads things from, or how to change settings.",
content: KILO_CONFIG,
},
]
@@ -2,9 +2,11 @@
All config lives in `kilo.json` (or `kilo.jsonc`). Precedence low-to-high: remote well-known, global (`~/.config/kilo/kilo.json`), env `KILO_CONFIG`, project `./kilo.json`, `.kilo/kilo.json`, `KILO_CONFIG_CONTENT`, managed (see Config File Locations). Deep-merged; later wins.
This also covers where Kilo looks for config files, commands, agents, and skills across project, global, and legacy paths such as `.kilo/`, `.kilocode/`, `.opencode/`, and `~/.config/kilo/`.
## Commands (`.kilo/command/*.md`)
Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`.
Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`. Commands can live in `.kilo/`, `.kilocode/`, `.opencode/`, and global config roots, with both `command/` and `commands/` directory names supported. See Config File Locations for the full search order.
```yaml
---
@@ -20,8 +22,28 @@ Reference files with @file and shell output with !`cmd`.
Template variables: `$1`-`$N` (positional args), `$ARGUMENTS` (full string), `@file` (file contents), `` !`cmd` `` (shell output).
### Finding a named command
When asked where `/name` lives, do not search only the repo root. Search these roots explicitly, and use an explicit search `path` for each one:
1. `~/.config/kilo/`
2. `~/.kilo/`
3. `~/.kilocode/`
4. `~/.opencode/`
5. The `KILO_CONFIG_DIR` directory (if the env var is set)
6. project `.kilo/`, `.kilocode/`, and `.opencode/` directories from the current working directory up to the worktree root
Use exact patterns first:
- `**/command/<name>.md`
- `**/commands/<name>.md`
If found, return the full path. If not found in those roots, explain that the command is not present in the loaded config paths.
## Agents (`.kilo/agent/*.md`)
Also loaded from `.kilocode/` and `.opencode/` directories (legacy), and plural `agents/` variants.
```yaml
---
description: When to use this agent
@@ -41,6 +63,10 @@ System prompt for this agent.
`mode` values: `primary` = selectable as main agent, `subagent` = only via Task tool, `all` = both.
## Workflows (legacy)
Markdown files in `.kilo/workflows/` or `.kilocode/workflows/` (project-level) and `~/.kilo/workflows/` or `~/.kilocode/workflows/` (global). These are automatically converted to commands at startup. The filename (minus `.md`) becomes the command name. Project workflows override global ones with the same name.
## Permissions
Scalar form applies to all patterns. Object form maps glob patterns to actions. Evaluated top-to-bottom; first match wins.
@@ -127,7 +153,7 @@ Additional skill directories and remote URLs:
}
```
Skills are markdown files at `skills/<name>/SKILL.md` with `name` and `description` in frontmatter.
Skills are markdown files at `skills/<name>/SKILL.md` (or `skill/<name>/SKILL.md`) with `name` and `description` in frontmatter. Discovered inside `.kilo/`, `.kilocode/`, and `.opencode/` directories.
## Other Top-Level Fields
@@ -200,12 +226,49 @@ Toggle notifications, Toggle animations, Toggle diff wrapping, Toggle sidebar (`
## Config File Locations
| Scope | Path |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `./kilo.json`, `./.kilo/kilo.json` |
| Global | `~/.config/kilo/kilo.json` |
| Managed | Linux: `/etc/kilo/kilo.json`, macOS: `/Library/Application Support/kilo/kilo.json`, Windows: `%ProgramData%\kilo\kilo.json` (enterprise, highest priority) |
| Commands | `.kilo/command/*.md` (project), `~/.config/kilo/command/*.md` (global) |
| Agents | `.kilo/agent/*.md` (project), `~/.config/kilo/agent/*.md` (global) |
| Skills | `.kilo/skill/*/SKILL.md`, `.kilo/skills/*/SKILL.md` |
| Instructions | `AGENTS.md`, `.kilo/instructions.md`, glob patterns from `instructions` |
### Config files (kilo.json)
| Scope | Path |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `./kilo.json`, `./kilo.jsonc`, `./opencode.json` (legacy), `./opencode.jsonc` (legacy) |
| Global | `~/.config/kilo/kilo.json`, `~/.config/kilo/kilo.jsonc`, `~/.config/kilo/opencode.json` (legacy), `~/.config/kilo/opencode.jsonc` (legacy), `~/.config/kilo/config.json` (legacy) |
| Managed | Linux: `/etc/kilo/`, macOS: `/Library/Application Support/kilo/`, Windows: `%ProgramData%\kilo\` — loads `kilo.json`, `kilo.jsonc`, `opencode.json`, `opencode.jsonc` (enterprise, highest priority) |
Each config directory (`.kilo/`, `.kilocode/`, `.opencode/`) can also contain `kilo.json`, `kilo.jsonc`, `opencode.json`, or `opencode.jsonc`.
### Config directories
Three directory names are scanned: `.kilo` (modern), `.kilocode` (legacy), `.opencode` (legacy). All three are checked at each level:
- **Project**: walks up from CWD to the git worktree root, checking for all three at each directory level
- **Home**: `~/.kilo/`, `~/.kilocode/`, `~/.opencode/`
- **XDG global**: `~/.config/kilo/` (always loaded, lowest file-based precedence)
### Commands, agents, modes, plugins
Glob patterns run inside every discovered config directory (including legacy):
| Type | Pattern |
| ------- | ---------------------------- |
| Command | `{command,commands}/**/*.md` |
| Agent | `{agent,agents}/**/*.md` |
| Mode | `{mode,modes}/*.md` |
| Plugin | `{plugin,plugins}/*.{ts,js}` |
Example: `~/.config/kilo/command/*.md` (modern global), `~/.kilocode/command/*.md` (legacy global), `.opencode/commands/*.md` (legacy project) all load commands.
### Skills and instructions
| Scope | Path |
| ------------ | -------------------------------------------------------------------------------------- |
| Skills | `{skill,skills}/<name>/SKILL.md` inside any config directory |
| Instructions | `AGENTS.md`, `CLAUDE.md`, `CONTEXT.md`, glob patterns from `instructions` config field |
### Environment variable overrides
| Variable | Description |
| ----------------------------- | ---------------------------------------------------------------- |
| `KILO_CONFIG` | Path to an additional config file (loaded after global) |
| `KILO_CONFIG_DIR` | Path to an additional config directory (appended to search list) |
| `KILO_CONFIG_CONTENT` | Inline JSON config string (high precedence, after project dirs) |
| `KILO_DISABLE_PROJECT_CONFIG` | Skip all project-level config (files and directories) |
@@ -62,6 +62,29 @@ export const ConfigRoutes = lazy(() =>
return c.json(config)
},
)
// kilocode_change start
.get(
"/warnings",
describeRoute({
summary: "Get config warnings",
description: "Get warnings generated during config loading (e.g., invalid JSON, schema errors).",
operationId: "config.warnings",
responses: {
200: {
description: "Config warnings",
content: {
"application/json": {
schema: resolver(Config.Warning.array()),
},
},
},
},
}),
async (c) => {
return c.json(await Config.warnings())
},
)
// kilocode_change end
.get(
"/providers",
describeRoute({
@@ -0,0 +1,234 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
Config.global.reset()
})
describe("config resilience", () => {
test("skips invalid agent markdown configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, ".kilo", "agent", "skip.md"),
`---
mode: "banana"
---
Broken agent prompt`,
)
await Filesystem.write(
path.join(dir, ".kilo", "agent", "keep.md"),
`---
model: test/model
---
Valid agent prompt`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
expect(cfg.agent?.["skip"]).toBeUndefined()
expect(cfg.agent?.["keep"]).toMatchObject({
name: "keep",
model: "test/model",
prompt: "Valid agent prompt",
})
},
})
})
test("reports a warning for invalid agent markdown configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, ".kilo", "agent", "skip.md"),
`---
mode: "banana"
---
Broken agent prompt`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
const warns = await Config.warnings()
expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("mode"))).toBe(true)
},
})
})
test("skips invalid command markdown configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, ".kilo", "command", "skip.md"),
`---
subtask: "banana"
---
Broken command template`,
)
await Filesystem.write(
path.join(dir, ".kilo", "command", "keep.md"),
`---
description: Valid command
---
Valid command template`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
expect(cfg.command?.["skip"]).toBeUndefined()
expect(cfg.command?.["keep"]).toEqual({
description: "Valid command",
template: "Valid command template",
})
},
})
})
test("reports a warning for invalid command markdown configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, ".kilo", "command", "skip.md"),
`---
subtask: "banana"
---
Broken command template`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
const warns = await Config.warnings()
expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("subtask"))).toBe(true)
},
})
})
test("collects warnings for invalid agent markdown configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, ".kilo", "agent", "broken.md"),
`---
mode: "banana"
---
Broken agent`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
const warns = await Config.warnings()
expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true)
},
})
})
test("collects warnings for invalid command markdown configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, ".kilo", "command", "broken.md"),
`---
subtask: "banana"
---
Broken command`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
const warns = await Config.warnings()
expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true)
},
})
})
test("collects warnings for invalid JSON in .kilo directory config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, ".kilo", "kilo.json"), "{ not valid json !!!")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
const warns = await Config.warnings()
// Config loading should not crash
expect(cfg).toBeDefined()
// Warning should reference the bad file
expect(warns.some((w) => w.path.includes("kilo.json") && w.message.includes("not valid JSON"))).toBe(true)
},
})
})
test("collects warnings for invalid schema in .kilo directory config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, ".kilo", "kilo.json"), JSON.stringify({ unknownField: true }))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
const warns = await Config.warnings()
expect(cfg).toBeDefined()
expect(warns.some((w) => w.path.includes("kilo.json") && w.message.includes("invalid"))).toBe(true)
},
})
})
test("returns empty warnings when config is valid", async () => {
await using tmp = await tmpdir({
config: { model: "test/model" },
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
const warns = await Config.warnings()
expect(warns).toEqual([])
},
})
})
})
+32
View File
@@ -109,4 +109,36 @@ Use this skill.
process.env.KILO_TEST_HOME = home
}
})
test("built-in kilo-config includes named command lookup guidance", async () => {
await using tmp = await tmpdir({ git: true })
const home = process.env.KILO_TEST_HOME
process.env.KILO_TEST_HOME = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await SkillTool.init()
const ctx: Tool.Context = {
...baseCtx,
ask: async () => {},
}
const result = await tool.execute({ name: "kilo-config" }, ctx)
expect(tool.description).toContain("where it loads things from")
expect(result.metadata.dir).toBe("builtin")
expect(result.output).toContain("### Finding a named command")
expect(result.output).toContain("`~/.config/kilo/`")
expect(result.output).toContain("`~/.kilocode/`")
expect(result.output).toContain("`**/command/<name>.md`")
expect(result.output).toContain("explicit search `path`")
},
})
} finally {
process.env.KILO_TEST_HOME = home
}
})
})
+31
View File
@@ -21,6 +21,7 @@ import type {
ConfigProvidersResponses,
ConfigUpdateErrors,
ConfigUpdateResponses,
ConfigWarningsResponses,
EnhancePromptEnhanceErrors,
EnhancePromptEnhanceResponses,
EventSubscribeResponses,
@@ -843,6 +844,36 @@ export class Config2 extends HeyApiClient {
})
}
/**
* Get config warnings
*
* Get warnings generated during config loading (e.g., invalid JSON, schema errors).
*/
public warnings<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ConfigWarningsResponses, unknown, ThrowOnError>({
url: "/config/warnings",
...options,
...params,
})
}
/**
* List config providers
*
+23
View File
@@ -2462,6 +2462,29 @@ export type ConfigUpdateResponses = {
export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses]
export type ConfigWarningsData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/config/warnings"
}
export type ConfigWarningsResponses = {
/**
* Config warnings
*/
200: Array<{
path: string
message: string
detail?: string
}>
}
export type ConfigWarningsResponse = ConfigWarningsResponses[keyof ConfigWarningsResponses]
export type ConfigProvidersData = {
body?: never
path?: never
+441 -1719
View File
File diff suppressed because it is too large Load Diff