Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
66 changes: 34 additions & 32 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,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 +737,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 +754,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 +786,33 @@ 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 - only file plugins need the config directory's local plugin dependency
if (plugins.some((plugin) => ConfigPlugin.pluginSpecifier(plugin).startsWith("file://"))) {
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 end
}

if (process.env.KILO_CONFIG_CONTENT) {
Expand Down
192 changes: 185 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,182 @@ 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) => Effect.zipRight(svc.get(), svc.waitForDependencies())).pipe(
Comment thread
johnnyeric marked this conversation as resolved.
Outdated
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.zipRight(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.fork)
yield* Effect.yieldNow()
const status = yield* Fiber.poll(fiber)
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) => Effect.zipRight(svc.get(), 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) => Effect.zipRight(svc.get(), 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