From d747d70fc94aa72880368dab532c8c4f59f66ddb Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Sun, 26 Apr 2026 10:50:51 +0800 Subject: [PATCH 1/2] fix(cli): prefer Kilo-branded config paths in kilo plugin command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kilo plugin now writes to .kilo/kilo.json(c) instead of .opencode/opencode.json(c) for fresh installs. Existing projects with .opencode/ or .kilocode/ directories continue to work — patchDir detects existing directories and writes there. Changes: - patchDir: async, searches .kilocode → .kilo → .opencode, defaults to .kilo - patchNames: returns ["kilo", "opencode"] for server kind - patchOne: iterates all name variants to find existing config files - PatchDeps.files type: accepts any string name - Tests updated + 2 new backward-compat tests added Closes #9503 --- .changeset/kilo-plugin-config-paths.md | 7 ++ packages/opencode/src/cli/cmd/plug.ts | 2 +- packages/opencode/src/plugin/install.ts | 33 +++++--- packages/opencode/test/plugin/install.test.ts | 82 ++++++++++++++----- 4 files changed, 90 insertions(+), 34 deletions(-) create mode 100644 .changeset/kilo-plugin-config-paths.md diff --git a/.changeset/kilo-plugin-config-paths.md b/.changeset/kilo-plugin-config-paths.md new file mode 100644 index 00000000000..0923068d215 --- /dev/null +++ b/.changeset/kilo-plugin-config-paths.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +--- + +fix(cli): prefer Kilo-branded config paths in `kilo plugin` command + +`kilo plugin` now writes to `.kilo/kilo.json(c)` instead of `.opencode/opencode.json(c)` for fresh installs. Existing projects that already have `.opencode/` or `.kilocode/` config directories continue to work — the command detects existing directories and writes there. diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index 9dfda16d645..479e2edf89f 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/packages/opencode/src/cli/cmd/plug.ts @@ -28,7 +28,7 @@ export type PlugDeps = { readText: (file: string) => Promise write: (file: string, text: string) => Promise exists: (file: string) => Promise - files: (dir: string, name: "opencode" | "tui") => string[] + files: (dir: string, name: string) => string[] // kilocode_change — accept kilo/opencode/tui global: string } diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 0525a7ba0b0..9ffb8a505cb 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -31,7 +31,7 @@ export type PatchDeps = { readText: (file: string) => Promise write: (file: string, text: string) => Promise exists: (file: string) => Promise - files: (dir: string, name: "opencode" | "tui") => string[] + files: (dir: string, name: string) => string[] // kilocode_change — accept kilo/opencode/tui } export type PatchInput = { @@ -330,25 +330,34 @@ export async function readPluginManifest(target: string): Promise { - const name = patchName(target.kind) - await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(path.join(dir, name))}`) + const names = patchNames(target.kind) // kilocode_change + const allFiles: string[] = [] + for (const name of names) allFiles.push(...dep.files(dir, name)) // kilocode_change + await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(allFiles[0])}`) - const files = dep.files(dir, name) - let cfg = files[0] - for (const file of files) { + let cfg = allFiles[0] // kilocode_change + for (const file of allFiles) { // kilocode_change if (!(await dep.exists(file))) continue cfg = file break @@ -419,7 +428,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea } export async function patchPluginConfig(input: PatchInput, dep: PatchDeps = defaultPatchDeps): Promise { - const dir = patchDir(input) + const dir = await patchDir(input, dep) // kilocode_change — async to check existing dirs const items: PatchItem[] = [] for (const target of input.targets) { const hit = await patchOne(dir, target, input.spec, Boolean(input.force), dep) diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts index f125f188a72..ae42ab91de0 100644 --- a/packages/opencode/test/plugin/install.test.ts +++ b/packages/opencode/test/plugin/install.test.ts @@ -122,8 +122,8 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) - const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc")) - const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc")) + const server = await read(path.join(tmp.path, ".kilo", "kilo.jsonc")) + const tui = await read(path.join(tmp.path, ".kilo", "tui.jsonc")) expect(server.plugin).toEqual(["acme@1.2.3"]) expect(tui.plugin).toEqual(["acme@1.2.3"]) }) @@ -144,8 +144,8 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) - const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc")) - const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc")) + const server = await read(path.join(tmp.path, ".kilo", "kilo.jsonc")) + const tui = await read(path.join(tmp.path, ".kilo", "tui.jsonc")) expect(server.plugin).toEqual([["acme@1.2.3", { custom: true, other: false }]]) expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]]) }) @@ -257,7 +257,7 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) - const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc")) + const server = await read(path.join(tmp.path, ".kilo", "kilo.jsonc")) expect(server.plugin).toEqual(["acme@1.2.3"]) }) @@ -366,8 +366,8 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) - expect(await Filesystem.exists(path.join(global, "opencode.jsonc"))).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(global, "kilo.jsonc"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(false) }) test("writes local scope under directory when vcs is not git", async () => { @@ -386,8 +386,8 @@ describe("plugin.install.task", () => { const ok = await run(ctxDir(directory, worktree)) expect(ok).toBe(true) - expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true) - expect(await Filesystem.exists(path.join(worktree, ".opencode", "opencode.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(directory, ".kilo", "kilo.jsonc"))).toBe(true) + expect(await Filesystem.exists(path.join(worktree, ".kilo", "kilo.jsonc"))).toBe(false) }) test("writes local scope under directory when worktree is root slash", async () => { @@ -404,7 +404,7 @@ describe("plugin.install.task", () => { const ok = await run(ctxRoot(directory)) expect(ok).toBe(true) - expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true) + expect(await Filesystem.exists(path.join(directory, ".kilo", "kilo.jsonc"))).toBe(true) }) test("writes tui local scope under directory when worktree is root slash", async () => { @@ -421,7 +421,7 @@ describe("plugin.install.task", () => { const ok = await run(ctxRoot(directory)) expect(ok).toBe(true) - expect(await Filesystem.exists(path.join(directory, ".opencode", "tui.jsonc"))).toBe(true) + expect(await Filesystem.exists(path.join(directory, ".kilo", "tui.jsonc"))).toBe(true) }) test("writes only tui config for tui-only plugins", async () => { @@ -436,8 +436,8 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "tui.jsonc"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(false) }) test("writes tui config for oc-themes-only packages", async () => { @@ -454,10 +454,10 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "tui.jsonc"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(false) - const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc")) + const tui = await read(path.join(tmp.path, ".kilo", "tui.jsonc")) expect(tui.plugin).toEqual(["acme@1.2.3"]) }) @@ -473,8 +473,8 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(false) - expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false) - expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "tui.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(false) }) test("force replaces version in both server and tui configs", async () => { @@ -534,8 +534,8 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(false) - expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) - expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "tui.jsonc"))).toBe(false) }) test("returns false when manifest cannot be read", async () => { @@ -551,7 +551,7 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(false) - expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(false) }) test("returns false when install fails", async () => { @@ -565,6 +565,46 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(false) + }) + + test("prefers existing .kilo dir over .opencode default", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, ["server"]) + const kiloDir = path.join(tmp.path, ".kilo") + await fs.mkdir(kiloDir, { recursive: true }) + const run = createPlugTask( + { + mod: "acme@1.2.3", + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(true) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) }) + + test("falls back to existing .opencode dir for legacy projects", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, ["server"]) + const opencodeDir = path.join(tmp.path, ".opencode") + const opencodeFile = path.join(opencodeDir, "opencode.json") + await fs.mkdir(opencodeDir, { recursive: true }) + await Bun.write(opencodeFile, JSON.stringify({ plugin: ["seed@1.0.0"] }, null, 2)) + const run = createPlugTask( + { + mod: "acme@1.2.3", + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.json"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(false) + const json = await read(opencodeFile) + expect(json.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"]) + }) }) From ec1eac2ac664326d514aa073dedef46673e7b7d0 Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Sun, 26 Apr 2026 10:57:00 +0800 Subject: [PATCH 2/2] fix: detect config files (not just dirs) to prevent split config Address review feedback: a .kilo/ directory may exist for agents/modes while plugin config lives in .opencode/opencode.json(c). Now patchDir checks for actual config files in each directory, preventing accidental config splitting. Added test: 'does not split config when .kilo dir exists but plugin config is in .opencode' --- packages/opencode/src/plugin/install.ts | 17 ++++++++-- packages/opencode/test/plugin/install.test.ts | 31 +++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 9ffb8a505cb..d0f4f9a5e80 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -335,9 +335,20 @@ async function patchDir(input: PatchInput, dep: PatchDeps) { if (input.global) return input.config ?? Global.Path.config const git = input.vcs === "git" && input.worktree !== "/" const root = git ? input.worktree : input.directory - for (const name of [".kilocode", ".kilo", ".opencode"]) { - const candidate = path.join(root, name) - if (await dep.exists(candidate)) return candidate + // Check for existing config files first — a .kilo/ dir may exist for agents/modes + // while plugin config still lives in .opencode/opencode.json(c). + const configNames = ["kilo", "opencode"] + for (const dir of [".kilocode", ".kilo", ".opencode"]) { + const candidate = path.join(root, dir) + for (const name of configNames) { + for (const file of dep.files(candidate, name)) { + if (await dep.exists(file)) return candidate + } + } + // Also check tui config files in the directory + for (const file of dep.files(candidate, "tui")) { + if (await dep.exists(file)) return candidate + } } return path.join(root, ".kilo") } diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts index ae42ab91de0..ca729066178 100644 --- a/packages/opencode/test/plugin/install.test.ts +++ b/packages/opencode/test/plugin/install.test.ts @@ -568,11 +568,12 @@ describe("plugin.install.task", () => { expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(false) }) - test("prefers existing .kilo dir over .opencode default", async () => { + test("prefers existing .kilo dir with config over .opencode default", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"]) const kiloDir = path.join(tmp.path, ".kilo") await fs.mkdir(kiloDir, { recursive: true }) + await Bun.write(path.join(kiloDir, "kilo.json"), JSON.stringify({}, null, 2)) const run = createPlugTask( { mod: "acme@1.2.3", @@ -582,10 +583,36 @@ describe("plugin.install.task", () => { const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.jsonc"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "kilo.json"))).toBe(true) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) }) + test("does not split config when .kilo dir exists but plugin config is in .opencode", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, ["server"]) + // .kilo exists (for agents/modes) but has no plugin config + const kiloDir = path.join(tmp.path, ".kilo") + await fs.mkdir(kiloDir, { recursive: true }) + // Plugin config lives in .opencode + const opencodeDir = path.join(tmp.path, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Bun.write(path.join(opencodeDir, "opencode.json"), JSON.stringify({ plugin: ["seed@1.0.0"] }, null, 2)) + const run = createPlugTask( + { + mod: "acme@1.2.3", + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(true) + // Should update existing .opencode config, not create split in .kilo + const json = await read(path.join(opencodeDir, "opencode.json")) + expect(json.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"]) + expect(await Filesystem.exists(path.join(kiloDir, "kilo.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(kiloDir, "kilo.json"))).toBe(false) + }) + test("falls back to existing .opencode dir for legacy projects", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"])