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/load-bundled-atomic-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Load the bundled Atomic Chat integration without attempting to install an unpublished npm plugin.
36 changes: 11 additions & 25 deletions packages/opencode/src/kilocode/atomic-chat-feature.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,23 @@
import { pathToFileURL } from "url"
import { ATOMIC_CHAT_PLUGIN } from "@kilocode/plugin-atomic-chat"
import { parsePluginSpecifier } from "@/plugin/shared"

type PluginSpec = string | [string, Record<string, unknown>]

type Req = {
resolve: (id: string) => string
}

type LogLike = {
debug: (msg: string, data?: Record<string, unknown>) => void
export function isAtomicChatPlugin(item: PluginSpec): boolean {
const spec = typeof item === "string" ? item : item[0]
const parsed = parsePluginSpecifier(spec)
if (!parsed.version.startsWith("npm:")) return parsed.pkg === ATOMIC_CHAT_PLUGIN
if (!parsed.version.startsWith(`npm:${ATOMIC_CHAT_PLUGIN}`)) return false
const version = parsed.version.slice(`npm:${ATOMIC_CHAT_PLUGIN}`.length)
return version === "" || version.startsWith("@")
}

export function hasAtomicChatPlugin(plugins: readonly PluginSpec[]): boolean {
return plugins.some((item) => {
const spec = typeof item === "string" ? item : item[0]
return spec.includes("plugin-atomic-chat") || spec === ATOMIC_CHAT_PLUGIN
})
}

export function resolveAtomicChatPlugin(req: Req, log?: LogLike): string {
try {
const file = req.resolve(ATOMIC_CHAT_PLUGIN)
return pathToFileURL(file).href
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
log?.debug("failed to resolve atomic chat plugin package, using package marker", { error })
return ATOMIC_CHAT_PLUGIN
}
return plugins.some(isAtomicChatPlugin)
}

export function ensureAtomicChatPlugin(items: readonly PluginSpec[], plugin?: string): PluginSpec[] {
export function ensureAtomicChatPlugin(items: readonly PluginSpec[]): PluginSpec[] {
const plugins = [...items]
if (!plugin) return plugins
if (hasAtomicChatPlugin(plugins)) return plugins
return [...plugins, plugin]
return [...plugins, ATOMIC_CHAT_PLUGIN]
}
11 changes: 7 additions & 4 deletions packages/opencode/src/kilocode/config/default-plugins.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createRequire } from "module"
import { ConfigPlugin } from "@/config/plugin"
import { isIndexingPlugin } from "@kilocode/kilo-indexing/detect"
import { ensureAtomicChatPlugin, resolveAtomicChatPlugin } from "@/kilocode/atomic-chat-feature"
import { ensureAtomicChatPlugin, isAtomicChatPlugin } from "@/kilocode/atomic-chat-feature"
import { ensureIndexingPlugin, resolveIndexingPlugin } from "@/kilocode/indexing-feature"

type Log = {
Expand All @@ -19,12 +19,14 @@ export namespace KilocodeDefaultPlugins {

if (!opts.disabled) {
plugins = ensureIndexingPlugin(plugins, resolveIndexingPlugin(req, opts.log))
plugins = ensureAtomicChatPlugin(plugins, resolveAtomicChatPlugin(req, opts.log))
plugins = ensureAtomicChatPlugin(plugins)
}

cfg.plugin = plugins
// Built-in indexing is not loaded through external plugins and must not wait for their setup.
const origins = cfg.plugin_origins?.filter((item) => !isIndexingPlugin(item.spec))
// Built-in plugins are not loaded externally and must not wait for external plugin setup.
const origins = cfg.plugin_origins?.filter(
(item) => !isIndexingPlugin(item.spec) && !isAtomicChatPlugin(item.spec),
)
if (!origins) return cfg
if (opts.disabled) {
cfg.plugin_origins = origins
Expand All @@ -34,6 +36,7 @@ export namespace KilocodeDefaultPlugins {
cfg.plugin_origins = [
...origins,
...plugins
.filter((spec) => !isIndexingPlugin(spec) && !isAtomicChatPlugin(spec))
.filter((spec) => !known.has(ConfigPlugin.pluginSpecifier(spec)))
.map((spec) => ({ spec, source: "builtin", scope: "global" as const })),
]
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { KiloAuthPlugin } from "@kilocode/kilo-gateway" // kilocode_change
import { AtomicChatPlugin } from "@kilocode/plugin-atomic-chat" // kilocode_change
import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types"
import { RuntimeFlags } from "@/effect/runtime-flags"
Expand Down Expand Up @@ -62,6 +63,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pl
// kilocode_change start
const INTERNAL_PLUGINS: PluginInstance[] = [
KiloAuthPlugin,
AtomicChatPlugin,
CodexAuthPlugin,
CopilotAuthPlugin,
// kilocode_change - external auth plugins ship against @opencode-ai/plugin; bridge to our @kilocode/plugin types
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "@/util/filesystem"
import { ConfigPlugin } from "@/config/plugin"
import { Npm } from "@opencode-ai/core/npm"
import { isIndexingPlugin } from "@kilocode/kilo-indexing/detect" // kilocode_change
import { isAtomicChatPlugin } from "@/kilocode/atomic-chat-feature" // kilocode_change

const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
Expand Down Expand Up @@ -1509,7 +1511,11 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
expect(names).toContain("global-only@1.0.0")
expect(names).toContain("local-only@1.0.0")

expect(origins.map((item) => item.spec)).toEqual(plugins)
// kilocode_change start - bundled plugins intentionally have no external plugin origins
expect(origins.map((item) => item.spec)).toEqual(
plugins.filter((item) => !isIndexingPlugin(item) && !isAtomicChatPlugin(item)),
)
// kilocode_change end
const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
expect(hit?.scope).toBe("local")
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,99 @@
import { describe, expect, test } from "bun:test"
import type { ConfigPlugin } from "@/config/plugin"
import { hasAtomicChatPlugin } from "@/kilocode/atomic-chat-feature"
import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins"

const atomic = "@kilocode/plugin-atomic-chat"

describe("kilocode default atomic chat plugin", () => {
test("apply adds atomic chat plugin when default plugins are enabled", () => {
const cfg = { plugin: [] as string[] }
test("injects atomic chat without registering an external plugin origin", () => {
const external: ConfigPlugin.Origin = { spec: "global-plugin", source: "global", scope: "global" }
const cfg = { plugin: [external.spec], plugin_origins: [external] }

KilocodeDefaultPlugins.apply(cfg, { disabled: false })
expect(hasAtomicChatPlugin(cfg.plugin ?? [])).toBe(true)

expect(hasAtomicChatPlugin(cfg.plugin)).toBe(true)
expect(cfg.plugin_origins).toEqual([external])
})

test("apply does not add atomic chat plugin when default plugins are disabled", () => {
const cfg = { plugin: ["global-plugin-1"] as string[] }
test("does not add atomic chat plugin when default plugins are disabled", () => {
const cfg = { plugin: ["global-plugin-1"] }
KilocodeDefaultPlugins.apply(cfg, { disabled: true })
expect(hasAtomicChatPlugin(cfg.plugin ?? [])).toBe(false)
expect(hasAtomicChatPlugin(cfg.plugin)).toBe(false)
expect(cfg.plugin).toEqual(["global-plugin-1"])
})

test("apply does not duplicate atomic chat plugin", () => {
const cfg = { plugin: ["@kilocode/plugin-atomic-chat"] as string[] }
test("removes a persisted atomic chat marker from external plugin origins", () => {
const external: ConfigPlugin.Origin = { spec: "global-plugin", source: "global", scope: "global" }
const cfg = {
plugin: [atomic, external.spec],
plugin_origins: [{ spec: atomic, source: "builtin", scope: "global" as const }, external],
}

KilocodeDefaultPlugins.apply(cfg, { disabled: true })

expect(cfg.plugin).toEqual([atomic, external.spec])
expect(cfg.plugin_origins).toEqual([external])
})

test("does not duplicate atomic chat plugin", () => {
const cfg = { plugin: [atomic] }
KilocodeDefaultPlugins.apply(cfg, { disabled: false })
expect(cfg.plugin?.filter((p) => hasAtomicChatPlugin([p])).length).toBe(1)
expect(cfg.plugin.filter((plugin) => hasAtomicChatPlugin([plugin])).length).toBe(1)
})

test("treats a versioned atomic chat package as the bundled plugin", () => {
const spec = `${atomic}@7.3.46`
const cfg = {
plugin: [spec],
plugin_origins: [{ spec, source: "global", scope: "global" as const }],
}

KilocodeDefaultPlugins.apply(cfg, { disabled: false })

expect(cfg.plugin.filter((plugin) => hasAtomicChatPlugin([plugin]))).toEqual([spec])
expect(cfg.plugin_origins).toEqual([])
})

test("treats npm aliases as the bundled plugin", () => {
for (const spec of [
`npm:${atomic}`,
`npm:${atomic}@7.3.46`,
`atomic@npm:${atomic}`,
`atomic@npm:${atomic}@7.3.46`,
]) {
const cfg = {
plugin: [spec],
plugin_origins: [{ spec, source: "global", scope: "global" as const }],
}

KilocodeDefaultPlugins.apply(cfg, { disabled: false })

expect(cfg.plugin.filter((plugin) => hasAtomicChatPlugin([plugin]))).toEqual([spec])
expect(cfg.plugin_origins).toEqual([])
}
})

test("keeps aliases named atomic chat with another target external", () => {
const spec = `${atomic}@npm:other-plugin`
const origin: ConfigPlugin.Origin = { spec, source: "global", scope: "global" }
const cfg = { plugin: [spec], plugin_origins: [origin] }

KilocodeDefaultPlugins.apply(cfg, { disabled: false })

expect(cfg.plugin).toContain(atomic)
expect(cfg.plugin_origins).toEqual([origin])
})

test("keeps similarly named file plugins external", () => {
const spec = "file:///tmp/custom-plugin-atomic-chat.ts"
const origin: ConfigPlugin.Origin = { spec, source: "project", scope: "local" }
const cfg = { plugin: [spec], plugin_origins: [origin] }

KilocodeDefaultPlugins.apply(cfg, { disabled: false })

expect(cfg.plugin).toContain(spec)
expect(cfg.plugin).toContain(atomic)
expect(cfg.plugin_origins).toEqual([origin])
})
})
2 changes: 0 additions & 2 deletions packages/plugin-atomic-chat/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import { createAuthHook } from "./auth-hook"
import { LOG_PREFIX } from "../constants"

export const AtomicChatPlugin: Plugin = async (input: PluginInput) => {
console.log(`${LOG_PREFIX} Atomic Chat plugin initialized`)

const { client } = input

if (!client || typeof client !== "object") {
Expand Down
Loading