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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-project-plugin-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Avoid creating project-local dependency trees when configuration directories contain no file plugins.
50 changes: 17 additions & 33 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { isRecord } from "@/util/record"
import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { Context, Duration, Effect, Fiber, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { containsPath, type InstanceContext } from "../project/instance-context"
Expand Down Expand Up @@ -55,6 +55,7 @@ import {
IndexingSchema as KiloIndexingSchema,
} from "@kilocode/kilo-indexing/config"
import { unique } from "remeda"
import { installLocalPluginDependency, needsLocalPluginDependency } from "@/kilocode/config/plugin-deps"
// kilocode_change end
import { withTransientReadRetry } from "@/util/effect-http-client"
import * as Log from "@opencode-ai/core/util/log" // kilocode_change
Expand Down Expand Up @@ -721,6 +722,7 @@ const layer = Layer.effect(

// kilocode_change start
for (const dir of unique(directories)) {
const plugins: ConfigPluginV1.Spec[] = [] // kilocode_change - track file plugins contributed by this directory
const scope = primarySet.has(dir) ? "local" : undefined
// kilocode_change - trust {file:}/{env:} only for global-scoped config dirs, never project ones
const dirScope = scope ?? (yield* pluginScopeForSource(dir))
Expand All @@ -736,18 +738,14 @@ const layer = Layer.effect(
yield* Effect.logDebug(`loading config from ${source}`)
// kilocode_change - untrusted config dirs confine {file:} reads to projectRoot
const fileScope = dirTrusted ? undefined : { root: projectRoot, source }
yield* merge(
source,
yield* loadFile(source, authEnv, dirTrusted, fileScope, dirTrusted ? undefined : warnings).pipe(
// kilocode_change
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, source, err)
return Effect.succeed({} as Info)
}),
),
dirScope,
dirTrusted,
const next = yield* loadFile(source, authEnv, dirTrusted, fileScope, dirTrusted ? undefined : warnings).pipe(
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, source, err)
return Effect.succeed({} as Info)
}),
)
plugins.push(...(next.plugin ?? []))
yield* merge(source, next, dirScope, dirTrusted)
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
Expand All @@ -757,27 +755,6 @@ const layer = Layer.effect(

yield* ensureGitignore(dir).pipe(Effect.orDie)

const dep = yield* npmSvc
.install(dir, {
add: [
{
name: "@kilocode/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) })
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)

// kilocode_change start - propagate parse errors to the Warning accumulator
const sourceScopes = (names: readonly string[]) => [
...(dirSourceScope ? [dirSourceScope] : []),
Expand Down Expand Up @@ -810,7 +787,14 @@ const layer = Layer.effect(
// kilocode_change - Auto-discovered plugins under config directories are already local files, so ConfigPlugin.load
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
plugins.push(...list) // kilocode_change

@johnnyeric johnnyeric Aug 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment is not necessarily to the line i commented but more towards the entire file.

Would be great if we can extract parts of this code in upstream path into a kilo owned path (to minimize upstream conflicts on merge). Could you check if this can be ported into a helper at packages/opencode/src/kilocode/config/plugin-deps.ts for example, then changes in this file reduce and you can have a minimal call to the helper. It's good that tests are already in kilo path. Can you address that?

yield* mergePluginOrigins(dir, list, dirScope) // kilocode_change

// kilocode_change start
if (needsLocalPluginDependency(plugins)) {
deps.push(yield* installLocalPluginDependency(npmSvc, dir, InstallationVersion, InstallationLocal))
}
// kilocode_change end
}

if (process.env.KILO_CONFIG_CONTENT) {
Expand Down
30 changes: 30 additions & 0 deletions packages/opencode/src/kilocode/config/plugin-deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ConfigPlugin } from "@/config/plugin"
import { Npm } from "@opencode-ai/core/npm"
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { Effect, Exit } from "effect"

export function needsLocalPluginDependency(plugins: readonly ConfigPluginV1.Spec[]) {
return plugins.some((plugin) => ConfigPlugin.pluginSpecifier(plugin).startsWith("file://"))
}

export function installLocalPluginDependency(npm: Npm.Interface, dir: string, version: string, local: boolean) {
return npm
.install(dir, {
add: [
{
name: "@kilocode/plugin",
version: local ? undefined : version,
},
],
})
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) })
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
}
191 changes: 184 additions & 7 deletions packages/opencode/test/kilocode/config/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import { Effect, Layer, Option, Schema } from "effect"
import { Effect, Fiber, Layer, Logger, Option, Schema } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import path from "path"
import { Global } from "@opencode-ai/core/global"
Expand Down Expand Up @@ -43,12 +43,14 @@ const noopNpm = Layer.mock(Npm.Service)({
const unexpectedHttp = HttpClient.make((request) =>
Effect.die(`unexpected http request: ${request.method} ${request.url}`),
)
const layer = AppNodeBuilder.build(Config.node, [
[Auth.node, emptyAuth],
[Account.node, emptyAccount],
[Npm.node, noopNpm],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, unexpectedHttp)],
]).pipe(Layer.provideMerge(infra))
const make = (npm: Layer.Layer<Npm.Service>) =>
AppNodeBuilder.build(Config.node, [
[Auth.node, emptyAuth],
[Account.node, emptyAccount],
[Npm.node, npm],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, unexpectedHttp)],
]).pipe(Layer.provideMerge(infra))
const layer = make(noopNpm)

const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
const clear = () =>
Expand Down Expand Up @@ -945,6 +947,181 @@ describe("unset propagation across layered config files", () => {
})
})

describe("project plugin dependencies", () => {
async function sandbox(fn: (dir: string) => Promise<void>) {
await using home = await tmpdir()
await using tmp = await tmpdir()
const prev = Global.Path.config
;(Global.Path as { config: string }).config = home.path
await disposeAllInstances()

try {
await fn(tmp.path)
} finally {
;(Global.Path as { config: string }).config = prev
await disposeAllInstances()
}
}

test("does not install dependencies for an ordinary project config directory", async () => {
await sandbox(async (dir) => {
await writeConfig(path.join(dir, ".kilo"), { username: "kilo" })
const calls: Array<{ dir: string; name?: string }> = []
const npm = Layer.mock(Npm.Service)({
install: (dir, input) =>
Effect.sync(() => calls.push({ dir, name: input?.add[0]?.name })).pipe(Effect.asVoid),
add: () => Effect.die("not implemented"),
which: () => Effect.succeed(undefined),
})

await provideTestInstance({
directory: dir,
fn: () =>
Effect.runPromise(
Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe(
Effect.scoped,
Effect.provide(make(npm)),
),
),
})

expect(calls).toEqual([])
})
})

test("installs dependencies for an auto-discovered file plugin and waits for completion", async () => {
await sandbox(async (dir) => {
const config = path.join(dir, ".kilo")
await Filesystem.write(path.join(config, "plugin", "local.ts"), "export default {}")
const gate = Promise.withResolvers<void>()
const calls: Array<{ dir: string; name?: string }> = []
const npm = Layer.mock(Npm.Service)({
install: (dir, input) =>
Effect.sync(() => calls.push({ dir, name: input?.add[0]?.name })).pipe(
Effect.andThen(Effect.promise(() => gate.promise)),
),
add: () => Effect.die("not implemented"),
which: () => Effect.succeed(undefined),
})

const pending = await provideTestInstance({
directory: dir,
fn: () =>
Effect.runPromise(
Config.Service.use((svc) =>
Effect.gen(function* () {
yield* svc.get()
const fiber = yield* svc.waitForDependencies().pipe(Effect.forkChild)
const status = yield* Fiber.join(fiber).pipe(Effect.timeoutOption("10 millis"))
gate.resolve()
yield* Fiber.join(fiber)
return Option.isNone(status)
}),
).pipe(Effect.scoped, Effect.provide(make(npm))),
),
})

expect(pending).toBe(true)
expect(calls).toEqual([{ dir: config, name: "@kilocode/plugin" }])
})
})

test("installs dependencies for a file plugin declared in directory config", async () => {
await sandbox(async (dir) => {
const config = path.join(dir, ".kilo")
await writeConfig(config, { plugin: ["./local.ts"] })
await Filesystem.write(path.join(config, "local.ts"), "export default {}")
const calls: Array<{ dir: string; name?: string }> = []
const npm = Layer.mock(Npm.Service)({
install: (dir, input) =>
Effect.sync(() => calls.push({ dir, name: input?.add[0]?.name })).pipe(Effect.asVoid),
add: () => Effect.die("not implemented"),
which: () => Effect.succeed(undefined),
})

await provideTestInstance({
directory: dir,
fn: () =>
Effect.runPromise(
Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe(
Effect.scoped,
Effect.provide(make(npm)),
),
),
})

expect(calls).toEqual([{ dir: config, name: "@kilocode/plugin" }])
})
})

test("does not install dependencies for built-in or package plugins", async () => {
await sandbox(async (dir) => {
await writeConfig(path.join(dir, ".kilo"), {
plugin: ["@kilocode/kilo-indexing", "opencode-gitlab-auth"],
})
const calls: string[] = []
const npm = Layer.mock(Npm.Service)({
install: (dir) => Effect.sync(() => calls.push(dir)).pipe(Effect.asVoid),
add: () => Effect.die("not implemented"),
which: () => Effect.succeed(undefined),
})

await provideTestInstance({
directory: dir,
fn: () =>
Effect.runPromise(
Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe(
Effect.scoped,
Effect.provide(make(npm)),
),
),
})

expect(calls).toEqual([])
})
})

test("keeps a failed file plugin dependency install non-fatal and logs a warning", async () => {
await sandbox(async (dir) => {
const config = path.join(dir, ".kilo")
await writeConfig(config, { username: "loaded" })
await Filesystem.write(path.join(config, "plugins", "local.js"), "export default {}")
const logs: string[] = []
const logger = Logger.make(({ message }) => logs.push(String(message)))
const npm = Layer.mock(Npm.Service)({
install: (dir) =>
Effect.fail(
new Npm.InstallFailedError({
dir,
add: ["@kilocode/plugin"],
cause: new Error("test install failure"),
}),
),
add: () => Effect.die("not implemented"),
which: () => Effect.succeed(undefined),
})
const testLayer = make(npm).pipe(Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })))

const loaded = await provideTestInstance({
directory: dir,
fn: () =>
Effect.runPromise(
Config.Service.use((svc) =>
Effect.gen(function* () {
const result = yield* svc.get()
yield* svc.waitForDependencies()
return result.username
}),
).pipe(Effect.scoped, Effect.provide(testLayer)),
),
})

expect(loaded).toBe("loaded")
expect(logs.some((message) => message.includes("background dependency install failed"))).toBe(true)
})
})
})

describe("agent config", () => {
test("accepts delete sentinels for agent model and variant overrides", () => {
const patch = decode({ agent: { explore: { model: null, variant: null } } })
Expand Down
Loading