mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
feat: kilo provider & kilo auth plugin
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# @opencode-ai/kilo-auth-plugin
|
||||
|
||||
Authentication plugin for Kilo Gateway integration with OpenCode.
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin provides device authorization flow for authenticating with Kilo Gateway, making it appear as an authentication option in `opencode auth login`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Device Authorization Flow**: OAuth-style device flow for secure authentication
|
||||
- **Organization Support**: Select between personal account and organization accounts
|
||||
- **Default Model Fetching**: Automatically fetches and configures default model settings
|
||||
- **Progress Display**: Visual feedback during the authorization process
|
||||
|
||||
## Architecture
|
||||
|
||||
The plugin consists of:
|
||||
|
||||
- **polling.ts**: Generic polling utilities with timeout and progress tracking
|
||||
- **device-auth.ts**: Complete device authorization flow implementation
|
||||
- **profile.ts**: Profile and organization fetching/selection
|
||||
- **index.ts**: Plugin registration with OpenCode
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The plugin communicates with the following Kilo Gateway endpoints:
|
||||
|
||||
- `POST /api/device-auth/codes` - Initiate device authorization
|
||||
- `GET /api/device-auth/codes/{code}` - Poll authorization status
|
||||
- `GET /api/profile` - Fetch user profile and organizations
|
||||
- `GET /api/defaults` - Fetch default model configuration
|
||||
- `GET /api/organizations/{id}/defaults` - Fetch org-specific defaults
|
||||
|
||||
## Usage
|
||||
|
||||
The plugin is automatically registered as an internal plugin in OpenCode. When users run:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
```
|
||||
|
||||
"Kilo Gateway (Device Authorization)" will appear as the first authentication option.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Type check
|
||||
bun run typecheck
|
||||
|
||||
# Build
|
||||
bun run build
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
This plugin works in tandem with [`@opencode-ai/kilo-provider`](../kilo-provider) to provide complete Kilo Gateway integration with OpenCode.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/kilo-auth-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "KiloCode authentication plugin for OpenCode with device authorization flow",
|
||||
"keywords": [
|
||||
"kilo",
|
||||
"kilocode",
|
||||
"opencode",
|
||||
"auth",
|
||||
"plugin",
|
||||
"device-auth"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/kilo-provider": "workspace:*",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"open": "10.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Kilo Auth Plugin Configuration Constants
|
||||
* Centralized configuration for all API endpoints and settings
|
||||
*/
|
||||
|
||||
/** Base URL for Kilo API */
|
||||
export const KILO_API_BASE = "https://api.kilo.ai"
|
||||
|
||||
/** Device auth polling interval in milliseconds */
|
||||
export const POLL_INTERVAL_MS = 3000
|
||||
|
||||
/** Default model to use as fallback */
|
||||
export const DEFAULT_MODEL = "anthropic/claude-sonnet-4"
|
||||
|
||||
/** Token expiration duration in milliseconds (1 year) */
|
||||
export const TOKEN_EXPIRATION_MS = 365 * 24 * 60 * 60 * 1000
|
||||
@@ -0,0 +1,183 @@
|
||||
import open from "open"
|
||||
import { spinner } from "@clack/prompts"
|
||||
import type { DeviceAuthInitiateResponse, DeviceAuthPollResponse } from "./types.js"
|
||||
import { poll, formatTimeRemaining } from "./polling.js"
|
||||
import { getKiloProfile, getKiloDefaultModel, promptOrganizationSelection } from "./profile.js"
|
||||
import { KILO_API_BASE, POLL_INTERVAL_MS } from "./constants.js"
|
||||
|
||||
/**
|
||||
* Initiate device authorization flow
|
||||
* @returns Device authorization details
|
||||
* @throws Error if initiation fails
|
||||
*/
|
||||
async function initiateDeviceAuth(): Promise<DeviceAuthInitiateResponse> {
|
||||
const response = await fetch(`${KILO_API_BASE}/api/device-auth/codes`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
throw new Error("Too many pending authorization requests. Please try again later.")
|
||||
}
|
||||
throw new Error(`Failed to initiate device authorization: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data as DeviceAuthInitiateResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for device authorization status
|
||||
* @param code The verification code
|
||||
* @returns Poll response with status and optional token
|
||||
* @throws Error if polling fails
|
||||
*/
|
||||
async function pollDeviceAuth(code: string): Promise<DeviceAuthPollResponse> {
|
||||
const response = await fetch(`${KILO_API_BASE}/api/device-auth/codes/${code}`)
|
||||
|
||||
if (response.status === 202) {
|
||||
// Still pending
|
||||
return { status: "pending" }
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
// Denied by user
|
||||
return { status: "denied" }
|
||||
}
|
||||
|
||||
if (response.status === 410) {
|
||||
// Code expired
|
||||
return { status: "expired" }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to poll device authorization: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data as DeviceAuthPollResponse
|
||||
}
|
||||
|
||||
export interface DeviceAuthResult {
|
||||
token: string
|
||||
organizationId?: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the device authorization flow
|
||||
* @returns Authentication result with token, org ID, and model
|
||||
* @throws Error if authentication fails
|
||||
*/
|
||||
export async function authenticateWithDeviceAuth(): Promise<DeviceAuthResult> {
|
||||
console.log("\n🔐 Starting browser-based authentication...\n")
|
||||
|
||||
// Step 1: Initiate device auth
|
||||
const s = spinner()
|
||||
s.start("Initiating device authorization")
|
||||
|
||||
let authData: DeviceAuthInitiateResponse
|
||||
authData = await initiateDeviceAuth()
|
||||
|
||||
const { code, verificationUrl, expiresIn } = authData
|
||||
s.stop("Device authorization initiated")
|
||||
|
||||
// Step 2: Display instructions and open browser
|
||||
console.log("\n📋 Verification Details:")
|
||||
console.log(` URL: ${verificationUrl}`)
|
||||
console.log(` Code: ${code}`)
|
||||
console.log(` Expires: ${Math.floor(expiresIn / 60)}:${String(expiresIn % 60).padStart(2, "0")}\n`)
|
||||
|
||||
console.log("Opening browser for authentication...")
|
||||
|
||||
// Open browser
|
||||
await open(verificationUrl).catch((err) => {
|
||||
console.log("\n⚠️ Could not open browser automatically. Please open the URL manually.")
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
// Step 3: Poll for authorization
|
||||
const startTime = Date.now()
|
||||
const maxAttempts = Math.ceil((expiresIn * 1000) / POLL_INTERVAL_MS)
|
||||
|
||||
s.start("Waiting for authorization")
|
||||
|
||||
let token: string
|
||||
let userEmail: string
|
||||
|
||||
const result = await poll<DeviceAuthPollResponse>({
|
||||
interval: POLL_INTERVAL_MS,
|
||||
maxAttempts,
|
||||
pollFn: async () => {
|
||||
const pollResult = await pollDeviceAuth(code)
|
||||
|
||||
// Update progress display
|
||||
const timeRemaining = formatTimeRemaining(startTime, expiresIn)
|
||||
s.message(`Waiting for authorization (${timeRemaining} remaining)`)
|
||||
|
||||
if (pollResult.status === "approved") {
|
||||
// Success!
|
||||
return {
|
||||
continue: false,
|
||||
data: pollResult,
|
||||
}
|
||||
}
|
||||
|
||||
if (pollResult.status === "denied") {
|
||||
return {
|
||||
continue: false,
|
||||
error: new Error("Authorization denied by user"),
|
||||
}
|
||||
}
|
||||
|
||||
if (pollResult.status === "expired") {
|
||||
return {
|
||||
continue: false,
|
||||
error: new Error("Authorization code expired"),
|
||||
}
|
||||
}
|
||||
|
||||
// Still pending, continue polling
|
||||
return {
|
||||
continue: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.token || !result.userEmail) {
|
||||
s.stop("Authentication failed")
|
||||
throw new Error("Invalid response from authorization server")
|
||||
}
|
||||
|
||||
token = result.token
|
||||
userEmail = result.userEmail
|
||||
|
||||
s.stop(`✓ Authenticated as ${userEmail}`)
|
||||
|
||||
// Step 4: Fetch profile to get organizations
|
||||
s.start("Fetching profile")
|
||||
const profileData = await getKiloProfile(token)
|
||||
s.stop("Profile fetched")
|
||||
|
||||
// Step 5: Prompt for organization selection
|
||||
let organizationId: string | undefined
|
||||
if (profileData.organizations && profileData.organizations.length > 0) {
|
||||
console.log() // Add spacing
|
||||
organizationId = await promptOrganizationSelection(profileData.organizations)
|
||||
}
|
||||
|
||||
// Step 6: Fetch default model
|
||||
s.start("Fetching default model")
|
||||
const model = await getKiloDefaultModel(token, organizationId)
|
||||
s.stop(`Default model: ${model}`)
|
||||
|
||||
// Step 7: Return auth result
|
||||
return {
|
||||
token,
|
||||
organizationId,
|
||||
model,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { authenticateWithDeviceAuth } from "./device-auth.js"
|
||||
import { KILO_API_BASE, TOKEN_EXPIRATION_MS } from "./constants.js"
|
||||
|
||||
/**
|
||||
* Kilo Gateway Authentication Plugin
|
||||
*
|
||||
* Provides device authorization flow for Kilo Gateway
|
||||
* to integrate with OpenCode's auth system.
|
||||
*/
|
||||
export const KiloAuthPlugin: Plugin = async (ctx) => {
|
||||
return {
|
||||
auth: {
|
||||
provider: "kilo",
|
||||
async loader(getAuth, providerInfo) {
|
||||
// Get the stored auth
|
||||
const auth = await getAuth()
|
||||
if (!auth) return {}
|
||||
|
||||
// For API auth, the key is the token directly
|
||||
if (auth.type === "api") {
|
||||
return {
|
||||
kilocodeToken: auth.key,
|
||||
}
|
||||
}
|
||||
|
||||
// For OAuth auth, access token contains the Kilo token
|
||||
// The accountId field is in OpenCode's Auth type but not exposed to SDK
|
||||
// so we access it as a property on the auth object
|
||||
if (auth.type === "oauth") {
|
||||
const result: Record<string, string> = {
|
||||
kilocodeToken: auth.access,
|
||||
}
|
||||
// accountId is present in OpenCode's OAuth schema but not in SDK's
|
||||
const maybeAccountId = (auth as any).accountId
|
||||
if (maybeAccountId) {
|
||||
result.kilocodeOrganizationId = maybeAccountId
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {}
|
||||
},
|
||||
methods: [
|
||||
{
|
||||
type: "oauth",
|
||||
label: "Kilo Gateway (Device Authorization)",
|
||||
async authorize() {
|
||||
// Execute the device auth flow
|
||||
const result = await authenticateWithDeviceAuth()
|
||||
|
||||
// Return in the format expected by OpenCode
|
||||
return {
|
||||
url: KILO_API_BASE,
|
||||
instructions: "Authenticated successfully with Kilo Gateway",
|
||||
method: "auto",
|
||||
async callback() {
|
||||
// Store using OAuth format to include organization ID
|
||||
// accountId field stores the organization ID
|
||||
return {
|
||||
type: "success",
|
||||
provider: "kilo",
|
||||
refresh: result.token, // Store token here too for redundancy
|
||||
access: result.token, // Primary token storage
|
||||
expires: Date.now() + TOKEN_EXPIRATION_MS,
|
||||
...(result.organizationId && { accountId: result.organizationId }),
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default KiloAuthPlugin
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PollOptions, PollResult } from "./types.js"
|
||||
|
||||
/**
|
||||
* Generic polling utility with timeout and progress tracking
|
||||
* @param options Polling configuration options
|
||||
* @returns The data from the successful poll result
|
||||
* @throws Error if polling times out or fails
|
||||
*/
|
||||
export async function poll<T>(options: PollOptions<T>): Promise<T> {
|
||||
const { interval, maxAttempts, pollFn } = options
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
// Wait before polling (except first attempt)
|
||||
if (attempt > 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, interval))
|
||||
}
|
||||
|
||||
const result: PollResult<T> = await pollFn()
|
||||
|
||||
// If polling should stop
|
||||
if (!result.continue) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (!result.data) {
|
||||
throw new Error("Polling stopped without data")
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Polling timeout: Maximum attempts reached")
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate time remaining in a human-readable format
|
||||
* @param startTime Start time in milliseconds
|
||||
* @param expiresIn Total expiration time in seconds
|
||||
* @returns Formatted time string (e.g., "9:45")
|
||||
*/
|
||||
export function formatTimeRemaining(startTime: number, expiresIn: number): string {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000)
|
||||
const remaining = Math.max(0, expiresIn - elapsed)
|
||||
const minutes = Math.floor(remaining / 60)
|
||||
const seconds = remaining % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { select } from "@clack/prompts"
|
||||
import type { KilocodeProfile, Organization } from "./types.js"
|
||||
import { KILO_API_BASE, DEFAULT_MODEL } from "./constants.js"
|
||||
|
||||
/**
|
||||
* Fetch user profile data from Kilo API
|
||||
* @param token - The Kilo API token
|
||||
* @returns Profile data including user info and organizations
|
||||
* @throws Error if request fails
|
||||
*/
|
||||
export async function getKiloProfile(token: string): Promise<KilocodeProfile> {
|
||||
const response = await fetch(`${KILO_API_BASE}/api/profile`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("Invalid token")
|
||||
}
|
||||
throw new Error(`Failed to fetch profile: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data as KilocodeProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the default model from Kilo API
|
||||
* @param token - The Kilo API token
|
||||
* @param organizationId - Optional organization ID for org-specific defaults
|
||||
* @returns The default model ID, or falls back to a default on error
|
||||
*/
|
||||
export async function getKiloDefaultModel(token: string, organizationId?: string): Promise<string> {
|
||||
const path = organizationId ? `/api/organizations/${organizationId}/defaults` : `/api/defaults`
|
||||
const url = `${KILO_API_BASE}${path}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch default model, using fallback: ${DEFAULT_MODEL}`)
|
||||
return DEFAULT_MODEL
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const defaultModel = data.defaultModel
|
||||
|
||||
if (!defaultModel) {
|
||||
console.warn(`No default model returned, using fallback: ${DEFAULT_MODEL}`)
|
||||
return DEFAULT_MODEL
|
||||
}
|
||||
|
||||
return defaultModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to select an organization or personal account
|
||||
* @param organizations List of organizations the user belongs to
|
||||
* @returns Organization ID or undefined for personal account
|
||||
*/
|
||||
export async function promptOrganizationSelection(organizations: Organization[]): Promise<string | undefined> {
|
||||
if (!organizations || organizations.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const choices = [
|
||||
{ label: "Personal Account", value: "personal", hint: "Use your personal account" },
|
||||
...organizations.map((org) => ({
|
||||
label: org.name,
|
||||
value: org.id,
|
||||
hint: `Organization`,
|
||||
})),
|
||||
]
|
||||
|
||||
const result = await select({
|
||||
message: "Select account",
|
||||
options: choices,
|
||||
})
|
||||
|
||||
if (result === "personal") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return result as string
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface DeviceAuthInitiateResponse {
|
||||
code: string
|
||||
verificationUrl: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export interface DeviceAuthPollResponse {
|
||||
status: "pending" | "approved" | "denied" | "expired"
|
||||
token?: string
|
||||
userEmail?: string
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface KilocodeProfile {
|
||||
email: string
|
||||
organizations?: Organization[]
|
||||
}
|
||||
|
||||
export interface PollOptions<T> {
|
||||
interval: number
|
||||
maxAttempts: number
|
||||
pollFn: () => Promise<PollResult<T>>
|
||||
}
|
||||
|
||||
export interface PollResult<T> {
|
||||
continue: boolean
|
||||
data?: T
|
||||
error?: Error
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig.json",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "preserve",
|
||||
"declaration": true,
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["es2022", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user