Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .changeset/kilo-plugin-config-paths.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/plug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export type PlugDeps = {
readText: (file: string) => Promise<string>
write: (file: string, text: string) => Promise<void>
exists: (file: string) => Promise<boolean>
files: (dir: string, name: "opencode" | "tui") => string[]
files: (dir: string, name: string) => string[] // kilocode_change — accept kilo/opencode/tui
global: string
}

Expand Down
44 changes: 32 additions & 12 deletions packages/opencode/src/plugin/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export type PatchDeps = {
readText: (file: string) => Promise<string>
write: (file: string, text: string) => Promise<void>
exists: (file: string) => Promise<boolean>
files: (dir: string, name: "opencode" | "tui") => string[]
files: (dir: string, name: string) => string[] // kilocode_change — accept kilo/opencode/tui
}

export type PatchInput = {
Expand Down Expand Up @@ -330,25 +330,45 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
}
}

function patchDir(input: PatchInput) {
// kilocode_change start — prefer Kilo-branded config dir when it already exists
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
return path.join(root, ".opencode")
// 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")
}
// kilocode_change end

function patchName(kind: Kind): "opencode" | "tui" {
if (kind === "server") return "opencode"
return "tui"
// kilocode_change start — prefer kilo.json(c) over opencode.json(c)
function patchNames(kind: Kind): string[] {
if (kind === "server") return ["kilo", "opencode"]
return ["tui"]
}
// kilocode_change end

async function patchOne(dir: string, target: Target, spec: string, force: boolean, dep: PatchDeps): Promise<PatchOne> {
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
Expand Down Expand Up @@ -419,7 +439,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
}

export async function patchPluginConfig(input: PatchInput, dep: PatchDeps = defaultPatchDeps): Promise<PatchResult> {
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)
Expand Down
109 changes: 88 additions & 21 deletions packages/opencode/test/plugin/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
})
Expand All @@ -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 }]])
})
Expand Down Expand Up @@ -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"])
})

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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"])
})

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -565,6 +565,73 @@ 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 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",
},
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.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"])
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"])
})
})