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
4 changes: 2 additions & 2 deletions .kilo/skills/v2-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Kilo moves onto upstream OpenCode v2 from a clean base, one feature at a time. V

- Run the CLI only through `bun dev` (`script/dev-isolated.ts`). It keeps all data in one profile directory. Never run `bun run --cwd packages/cli`, `dev:live` or `opencode` directly. They use the installed OpenCode data and service.
- Use a fresh profile per test: `KILO_V2_PROFILE=/tmp/kilo-v2-<id>`. Add `--standalone` so parallel agents do not share the service port.
- `bun dev` reuses the v1 Kilo login (read only). For a signed-out test, start it with `XDG_DATA_HOME=/tmp/kilo-v2-empty`.
- `bun dev` starts signed out and does not read the v1 login. Signed out, the Kilo Gateway serves its free models. Sign in with `/connect`, or start a signed-in test with `KILO_API_KEY` (and `KILO_ORG_ID` for a team) from the v1 auth file, so that no secret shows on screen.
- Do not edit the main checkout, `~/.config/kilo`, `~/.local/share/kilo` or OpenCode directories. `bun run extension` does not exist on v2.
- Models: `kilo/kilo-auto/free` by default. Use `kilo/deepseek/deepseek-v4.1-flash` only when a paid model is needed.
- Isolate tests too. Upstream tests can write into the real home and leave servers running. Run them with `T=$(mktemp -d)` and `env OPENCODE_TEST_HOME=$T XDG_DATA_HOME=$T/data XDG_CONFIG_HOME=$T/config XDG_STATE_HOME=$T/state XDG_CACHE_HOME=$T/cache bun test ...`. After the run, stop any of your processes that `pgrep -fl 'opencode-test-|opencode-service-lifecycle'` lists.
Expand Down Expand Up @@ -100,7 +100,7 @@ st type --submit --value "env KILO_V2_PROFILE=$T/profile bun $V2/script/dev-isol
st wait --text "ctrl+p commands" >/dev/null && screen
```

The TUI is ready when the last command prints the home screen (about 15 seconds). To test signed out, start the CLI with `env XDG_DATA_HOME=$T/empty KILO_V2_PROFILE=...` instead.
The TUI is ready when the last command prints the home screen (about 15 seconds). The CLI starts signed out. To test signed in, start it with `env KILO_API_KEY=$(jq -r .kilo.access ~/.local/share/kilo/auth.json) KILO_ORG_ID=$(jq -r '.kilo.accountId // empty' ~/.local/share/kilo/auth.json) KILO_V2_PROFILE=...` instead.

### 2. Test

Expand Down
16 changes: 16 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"@ai-sdk/vercel": "2.0.39",
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5",
"@kilocode/gateway-core": "workspace:*",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/kilocode/gateway-rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export * as KiloGatewayRpc from "./gateway-rpc.js"

import { Rpc } from "@opencode/schema/rpc"
import { Schema } from "effect"

// Standard Schemas, so the promise client of the TUI accepts the contract too.
const portable = Schema.toStandardSchemaV1
const Failure = { unauthorized: portable(Schema.Undefined), failed: portable(Schema.Undefined) }
const OrganizationID = Schema.NullOr(Schema.String)

export const Profile = Schema.Struct({
email: Schema.String,
organizations: Schema.Array(
Schema.Struct({ id: Schema.String, name: Schema.String, role: Schema.optional(Schema.String) }),
),
hasPersonalAccount: Schema.Boolean,
/** The team that requests use now. `null` is the personal account. */
organizationID: OrganizationID,
})

export type Profile = typeof Profile.Type

// The TUI uses this contract for the account picker after login and for /teams, as v1 uses
// `kilo.profile` and `kilo.organization.set`.
export const Definition = Rpc.define({
id: "kilocode.gateway",
methods: {
profile: {
input: portable(Schema.Struct({})),
output: portable(Profile),
errors: Failure,
},
"organization.set": {
input: portable(Schema.Struct({ organizationID: OrganizationID })),
output: portable(Schema.Struct({})),
errors: Failure,
},
},
events: {
organization: { schema: portable(Schema.Struct({ organizationID: OrganizationID })) },
},
})
177 changes: 177 additions & 0 deletions packages/core/src/kilocode/gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { KiloAccount, KiloDevice, KiloHeaders, KiloProfile, KiloServer } from "@kilocode/gateway-core"
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import { define } from "@opencode/plugin/effect/plugin"
import { Credential } from "@opencode/schema/credential"
import { IntegrationMethodID } from "@opencode/schema/integration-id"
import { Clock, Effect, Option, Schema, Scope, Stream } from "effect"
import { Bus } from "../bus.js"
import { KiloGatewayRpc } from "./gateway-rpc.js"

// v2 adapter for the Kilo Gateway (issue #14991, PR 1: login and teams).
// Kilo logic lives in @kilocode/gateway-core; this file only maps it onto the public plugin API.
// The `kilo` integration and provider come from the models.dev seed.
const id = "kilo"
const methodID = IntegrationMethodID.make("device")

// Written by the device method. Decoded again because stored credentials are read back from the database.
const Metadata = Schema.Struct({
server: Schema.optional(Schema.String),
organizationID: Schema.optional(Schema.NullOr(Schema.String)),
})
const decodeMetadata = Schema.decodeUnknownOption(Metadata)
// The team choice in plugin storage. `null` is the personal account.
const decodeSelection = Schema.decodeUnknownOption(Schema.Struct({ organizationID: Schema.NullOr(Schema.String) }))
// Published on the global bus (`{ global: true }`), so plugin instances of other locations also reload.
const OrganizationEvent = Bus.ephemeral({
type: `rpc.${KiloGatewayRpc.Definition.id}.organization`,
schema: { organizationID: Schema.NullOr(Schema.String) },
})

export const KiloGatewayPlugin = define<Bus.Service | Scope.Scope>({
id: "kilocode.gateway",
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const server = KiloServer.fromEnv()
let account: KiloAccount.Info | undefined
let envActive = false

const load = Effect.gen(function* () {
// Environment tokens (`KILO_API_KEY`, `KILOCODE_TOKEN`, `KILO_AUTH_CONTENT`) take precedence over stored
// credentials, matching v1 (`acd3d1ff9b`) and supporting headless/cloud consumers.
const envInput = KiloAccount.envToken()
if (envInput) {
const selection = decodeSelection(yield* ctx.storage.get("organization:env"))
envActive = true
account = KiloAccount.resolve({
...envInput,
server,
organizationID: Option.isSome(selection) ? selection.value.organizationID : envInput.organizationID,
})
return
}
envActive = false
const connection = yield* ctx.integration.connection.active(id)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const selection = connection ? decodeSelection(yield* ctx.storage.get(organizationKey(connection))) : Option.none()
account = credential ? toAccount(credential, Option.getOrUndefined(selection)) : undefined
})

yield* ctx.integration.transform((editor) => editor.method.update(device(server)))
yield* load
yield* ctx.provider.transform((editor) => {
if (!editor.get(id)) return
editor.update(id, (provider) => {
const { apiKey: _, ...rest } = provider.settings ?? {}
const settings = { ...rest, baseURL: KiloServer.gateway(account?.server ?? server) }
provider.headers = {
...provider.headers,
...KiloHeaders.provider({ version: ctx.app.version, organizationID: account?.organizationID }),
}
if (account) {
if (envActive) {
provider.activation = "enabled"
provider.settings = { ...settings, apiKey: account.token }
return
}
provider.activation = "auto"
provider.settings = settings
return
}
// Signed out, the integration has no connection and the provider would stay hidden. The Gateway
// serves its free models to the anonymous key, as in v1.
provider.activation = "enabled"
provider.settings = { ...settings, apiKey: KiloAccount.ANONYMOUS_KEY }
})
})
const reload = load.pipe(Effect.andThen(ctx.provider.reload()))
yield* ctx.rpc
.register(KiloGatewayRpc.Definition, {
profile: (_input, context) =>
Effect.gen(function* () {
if (!account) return yield* Effect.fail(context.error("unauthorized", "Not signed in to Kilo Gateway"))
const profile = yield* KiloProfile.get(account.server, account.token).pipe(
Effect.mapError((error) => context.error("failed", error.message)),
)
return {
email: profile.email,
organizations: profile.organizations,
hasPersonalAccount: profile.hasPersonalAccount !== false,
organizationID: account.organizationID,
}
}),
// Same as v1 `setOrganization`, which stores the team in the auth record of the login. The choice
// wins over the login default and is kept per connection.
"organization.set": (input, context) =>
Effect.gen(function* () {
const envInput = KiloAccount.envToken()
const connection = envInput ? undefined : yield* ctx.integration.connection.active(id)
const key = envInput ? "organization:env" : connection ? organizationKey(connection) : undefined
if (!key || !account)
return yield* Effect.fail(context.error("unauthorized", "Not signed in to Kilo Gateway"))
yield* ctx.storage.set(key, { organizationID: input.organizationID })
yield* reload
yield* bus.publish(OrganizationEvent, { organizationID: input.organizationID }, { global: true })
return {}
}),
})
.pipe(Effect.orDie)
yield* ctx.event.subscribe().pipe(
Stream.filter(
(event) =>
(event.type === "credential.switched" && event.data.integrationID === id) ||
event.type === OrganizationEvent.type,
),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
}),
})

function device(server: string) {
return {
integrationID: id,
method: { id: methodID, type: "oauth", label: "Kilo Gateway (Device Authorization)" },
authorize: () =>
Effect.gen(function* () {
const code = yield* KiloDevice.start(server)
return {
mode: "auto" as const,
url: code.verificationUrl,
instructions: `Enter code: ${code.code}`,
expiresAt: (yield* Clock.currentTimeMillis) + code.expiresIn * 1000,
callback: Effect.gen(function* () {
const login = yield* KiloDevice.login(server, code)
// v1 reuses the token as the refresh token and has no refresh endpoint.
return Credential.OAuth.make({
type: "oauth",
methodID,
access: login.token,
refresh: login.token,
expires: (yield* Clock.currentTimeMillis) + KiloDevice.TOKEN_LIFETIME_MS,
metadata: { server, organizationID: login.organizationID, email: login.email },
})
}),
}
}),
label: (credential) => (typeof credential.metadata?.email === "string" ? credential.metadata.email : undefined),
} satisfies IntegrationOAuthMethodRegistration
}

function organizationKey(
connection: { readonly type: "credential"; readonly id: string } | { readonly type: "env"; readonly name: string },
) {
return `organization:${connection.type === "credential" ? connection.id : `env:${connection.name}`}`
}

function toAccount(credential: Credential.Value, selection: { readonly organizationID: string | null } | undefined) {
if (credential.type === "external") return undefined
const metadata = Option.getOrUndefined(decodeMetadata(credential.metadata ?? {}))
return KiloAccount.resolve({
token: credential.type === "oauth" ? credential.access : credential.key,
server: metadata?.server,
// The team chosen after login wins over the login default.
organizationID: selection ? selection.organizationID : metadata?.organizationID,
})
}
4 changes: 2 additions & 2 deletions packages/core/src/plugin/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { DynamicProviderPlugin } from "./provider/dynamic.js"
import { GithubCopilotPlugin } from "./provider/github-copilot.js"
import { GitLabPlugin } from "./provider/gitlab.js"
import { GoogleVertexPlugin } from "./provider/google-vertex.js"
import { KiloPlugin } from "./provider/kilo.js"
import { KiloGatewayPlugin } from "../kilocode/gateway.js" // kilocode_change - Kilo Gateway plugin replaces upstream KiloPlugin
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
import { ModalPlugin } from "./provider/modal.js"
Expand Down Expand Up @@ -45,7 +45,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
GithubCopilotPlugin,
GitLabPlugin,
GoogleVertexPlugin,
KiloPlugin,
KiloGatewayPlugin, // kilocode_change - replaces KiloPlugin, which sends the OpenCode referer and title
LLMGatewayPlugin,
LMStudioPlugin,
ModalPlugin,
Expand Down
Loading
Loading