Skip to content
Closed
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
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
33 changes: 21 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,34 @@ 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")
for (const name of [".kilocode", ".kilo", ".opencode"]) {
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.

WARNING: Existing legacy plugin config can still get split

Many Kilo projects already have a .kilo/ directory for commands or agents even when their plugin config still lives in .opencode/opencode.json(c). This loop returns .kilo as soon as that directory exists, so kilo plugin will create a new .kilo/kilo.json(c) instead of updating the existing legacy config. It would be safer to choose the directory based on an existing config file, not just the directory name.

const candidate = path.join(root, name)
if (await dep.exists(candidate)) 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 +428,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
82 changes: 61 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,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"])
})
})