diff --git a/package.json b/package.json index 2220c0f943..cc9b9a4dfb 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "server:dev": "tsx --tsconfig server/tsconfig.json server/index.js", "server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js", "client": "vite", + "test:server": "tsx --tsconfig server/tsconfig.json --test \"server/**/*.test.ts\"", "build": "npm run build:client && npm run build:server", "build:client": "vite build", "prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"", diff --git a/public/api-docs.html b/public/api-docs.html index 03dbb9b890..06323648d5 100644 --- a/public/api-docs.html +++ b/public/api-docs.html @@ -834,6 +834,7 @@

Branch & PR Response

{ id: 'gemini', name: 'Google' }, { id: 'cursor', name: 'Cursor' }, { id: 'opencode', name: 'OpenCode' }, + { id: 'kiro', name: 'AWS Kiro' }, ]; async function populateModels() { diff --git a/public/icons/kiro-white.svg b/public/icons/kiro-white.svg new file mode 100644 index 0000000000..b42c8eac00 --- /dev/null +++ b/public/icons/kiro-white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/kiro.svg b/public/icons/kiro.svg new file mode 100644 index 0000000000..ce0b07dec5 --- /dev/null +++ b/public/icons/kiro.svg @@ -0,0 +1,4 @@ + + + + diff --git a/server/index.js b/server/index.js index 9ebe74d600..e4115f11dc 100755 --- a/server/index.js +++ b/server/index.js @@ -41,6 +41,10 @@ import { spawnOpenCode, abortOpenCodeSession, } from './opencode-cli.js'; +import { + spawnKiro, + abortKiroSession, +} from './kiro-cli.js'; import sessionManager from './sessionManager.js'; import { stripAnsiSequences, @@ -100,6 +104,7 @@ const wss = createWebSocketServer(server, { codex: queryCodex, gemini: spawnGemini, opencode: spawnOpenCode, + kiro: spawnKiro, }, abortFns: { claude: abortClaudeSDKSession, @@ -107,6 +112,7 @@ const wss = createWebSocketServer(server, { codex: abortCodexSession, gemini: abortGeminiSession, opencode: abortOpenCodeSession, + kiro: abortKiroSession, }, resolveToolApproval, getPendingApprovalsForSession, @@ -1224,6 +1230,20 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate }); } + // Kiro sessions report `context_usage_percentage` per turn but not a + // running total/used pair compatible with this endpoint. Defer accurate + // accounting to a follow-up; the UI will treat Kiro as "no budget" for + // now (parity with Cursor/Gemini). + if (provider === 'kiro') { + return res.json({ + used: 0, + total: 0, + breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 }, + unsupported: true, + message: 'Token usage tracking not available for Kiro sessions' + }); + } + if (provider === 'opencode') { const dbPath = getOpenCodeDatabasePath(); if (!fs.existsSync(dbPath)) { diff --git a/server/kiro-cli.js b/server/kiro-cli.js new file mode 100644 index 0000000000..ba6c75f92e --- /dev/null +++ b/server/kiro-cli.js @@ -0,0 +1,367 @@ +import { spawn } from 'node:child_process'; +import crypto from 'node:crypto'; + +import crossSpawn from 'cross-spawn'; + +import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; +import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; +import { createNormalizedMessage } from './shared/utils.js'; +import { StdioJsonRpcClient } from './modules/providers/list/kiro/stdio-jsonrpc-client.js'; + +const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +const KIRO_BIN = process.env.KIRO_PATH ?? 'kiro-cli'; + +// Tracks active Kiro processes by session id (or a temporary process key +// before the ACP `session/new` reply assigns a real sessionId). +const activeKiroProcesses = new Map(); + +const PROVIDER = 'kiro'; + +/** + * Kiro speaks ACP (Agent Client Protocol) — JSON-RPC 2.0 over stdio. Each + * `kiro-cli acp` invocation hosts ONE chat session that we drive with + * `initialize` → `session/new` (or `session/load` for resume) → `session/prompt`. + * + * Streamed agent events arrive as `session/update` notifications and are + * normalized into NormalizedMessage shapes the rest of the app already + * understands (text, tool_use, tool_result, complete, error). + */ + +/** + * Maps an ACP `session/update` notification into NormalizedMessage chunks. + * + * Event shapes (verified against kiro-cli 2.3.0): + * sessionUpdate: 'agent_message_chunk' → {content: {type, text}} + * sessionUpdate: 'tool_call' → {toolCallId, title, kind, locations[], rawInput} + * sessionUpdate: 'tool_call_chunk' → {toolCallId, ...} (progressive args) + * sessionUpdate: 'tool_call_update' → {toolCallId, status: 'completed'|'failed', ...} + */ +function normalizeAcpUpdate(params, sessionId) { + if (!params || typeof params !== 'object') { + return []; + } + + const update = params.update; + if (!update || typeof update !== 'object') { + return []; + } + + const kind = update.sessionUpdate; + const ts = new Date().toISOString(); + + if (kind === 'agent_message_chunk') { + const content = update.content; + const text = content && typeof content === 'object' && typeof content.text === 'string' + ? content.text + : ''; + if (!text) { + return []; + } + return [createNormalizedMessage({ + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: text, + })]; + } + + if (kind === 'tool_call') { + const toolId = typeof update.toolCallId === 'string' ? update.toolCallId : ''; + return [createNormalizedMessage({ + id: toolId || undefined, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: typeof update.title === 'string' ? update.title : (typeof update.kind === 'string' ? update.kind : 'tool'), + toolId, + toolInput: update.rawInput, + input: update.locations, + })]; + } + + if (kind === 'tool_call_update') { + const toolId = typeof update.toolCallId === 'string' ? update.toolCallId : ''; + const status = typeof update.status === 'string' ? update.status : 'completed'; + if (status !== 'completed' && status !== 'failed') { + // Intermediate (e.g. 'in_progress') updates are not surfaced to keep the + // history reader and the live stream byte-identical. + return []; + } + const isError = status === 'failed'; + let content = ''; + if (typeof update.output === 'string') { + content = update.output; + } else if (update.output && typeof update.output === 'object') { + try { content = JSON.stringify(update.output); } catch { content = ''; } + } + return [createNormalizedMessage({ + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId, + content, + isError, + })]; + } + + // tool_call_chunk and other progressive variants are intentionally dropped: + // the final tool_call carries the complete rawInput, so duplicating the + // streamed args would inflate the wire transcript without UI value. + return []; +} + +async function spawnKiro(command, options = {}, ws) { + const { sessionId, projectPath, cwd, model, agent, sessionSummary } = options; + const workingDir = cwd || projectPath || process.cwd(); + + // Process key starts as the existing sessionId (resume) or a placeholder + // we'll replace once `session/new` returns the real id. We expose the + // placeholder via `ws.setSessionId` so the frontend can target an abort + // BEFORE the real id arrives (the `session/new` round-trip can take 30+s + // while MCP servers boot). + const placeholderKey = sessionId || `pending-${crypto.randomUUID()}`; + let activeKey = placeholderKey; + let capturedSessionId = sessionId || null; + let sessionCreatedSent = false; + let terminalNotificationSent = false; + + if (!sessionId && typeof ws.setSessionId === 'function') { + // Frontend uses this id to send `abort-session` while we're still in the + // ACP handshake. Once `session/new` returns, the id flips and the swap + // below rekeys the active map. + ws.setSessionId(placeholderKey); + } + + // ACP `--model` and `--agent` are CLI flags applied to the FIRST session in + // the process. They must be passed at spawn time, not in `session/prompt` + // params (Kiro silently ignores `model`/`agent` on prompt). Resume mode + // reuses the existing session's model, so we only set them for new sessions. + const acpArgs = ['acp', '--trust-all-tools']; + if (!sessionId && model) { + acpArgs.push('--model', model); + } + if (!sessionId && agent) { + acpArgs.push('--agent', agent); + } + + const kiroProcess = spawnFunction(KIRO_BIN, acpArgs, { + cwd: workingDir, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); + activeKiroProcesses.set(activeKey, kiroProcess); + + console.log('Spawning Kiro CLI:', KIRO_BIN, acpArgs.join(' ')); + console.log('Working directory:', workingDir); + console.log('Session info - Input sessionId:', sessionId); + + const client = new StdioJsonRpcClient(kiroProcess, { + onStderr: (line) => console.error('Kiro CLI stderr:', line), + onParseError: (rawLine) => console.warn('Kiro ACP non-JSON line:', rawLine.slice(0, 200)), + }); + + const notifyTerminalState = ({ code = null, error = null } = {}) => { + if (terminalNotificationSent) { + return; + } + terminalNotificationSent = true; + + const finalSessionId = capturedSessionId || sessionId || activeKey; + if (code === 0 && !error) { + notifyRunStopped({ + userId: ws?.userId || null, + provider: PROVIDER, + sessionId: finalSessionId, + sessionName: sessionSummary, + stopReason: 'completed', + }); + return; + } + + notifyRunFailed({ + userId: ws?.userId || null, + provider: PROVIDER, + sessionId: finalSessionId, + sessionName: sessionSummary, + error: error || `Kiro CLI exited with code ${code}`, + }); + }; + + // Stream session/update notifications onto the websocket. + client.onNotification('session/update', (params) => { + const targetSessionId = capturedSessionId || sessionId || null; + const messages = normalizeAcpUpdate(params, targetSessionId); + for (const msg of messages) { + ws.send(msg); + } + }); + + // Kiro extension namespace: log for debugging, but do not surface to the UI + // in v1. Future enhancements (live MCP server status, credit usage) can hook + // here without touching the core normalization path. + client.onNotificationPrefix('_kiro.dev/', () => {}); + + return new Promise((resolve, reject) => { + let settled = false; + const settleOnce = (callback) => { + if (settled) return; + settled = true; + callback(); + }; + + kiroProcess.on('close', async (code) => { + activeKiroProcesses.delete(activeKey); + ws.send(createNormalizedMessage({ + kind: 'complete', + exitCode: code, + isNewSession: !sessionId && !!command, + sessionId: capturedSessionId || sessionId || activeKey, + provider: PROVIDER, + })); + notifyTerminalState({ code }); + if (code === 0) { + settleOnce(() => resolve()); + } else { + settleOnce(() => reject(new Error(`Kiro CLI exited with code ${code}`))); + } + }); + + kiroProcess.on('error', async (error) => { + console.error('Kiro CLI process error:', error); + activeKiroProcesses.delete(activeKey); + + const installed = await providerAuthService.isProviderInstalled(PROVIDER); + const errorContent = !installed + ? 'Kiro CLI is not installed. Install with: curl -fsSL https://cli.kiro.dev/install | bash' + : error.message; + + ws.send(createNormalizedMessage({ + kind: 'error', + content: errorContent, + sessionId: capturedSessionId || sessionId || null, + provider: PROVIDER, + })); + notifyTerminalState({ error }); + settleOnce(() => reject(error)); + }); + + // Drive the ACP handshake → new/load → prompt sequence. + (async () => { + try { + await client.request('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + }, + }); + + if (sessionId) { + // Resume an existing chat-store or ACP-store session by id. + await client.request('session/load', { sessionId, cwd: workingDir, mcpServers: [] }); + capturedSessionId = sessionId; + } else { + const newResult = await client.request('session/new', { + cwd: workingDir, + mcpServers: [], + }); + const result = newResult && typeof newResult === 'object' ? newResult : {}; + if (typeof result.sessionId === 'string' && result.sessionId) { + capturedSessionId = result.sessionId; + const previousKey = activeKey; + activeKey = capturedSessionId; + if (previousKey !== capturedSessionId) { + activeKiroProcesses.delete(previousKey); + activeKiroProcesses.set(activeKey, kiroProcess); + } + if (typeof ws.setSessionId === 'function') { + ws.setSessionId(capturedSessionId); + } + if (!sessionCreatedSent) { + sessionCreatedSent = true; + ws.send(createNormalizedMessage({ + kind: 'session_created', + newSessionId: capturedSessionId, + cwd: workingDir, + sessionId: capturedSessionId, + provider: PROVIDER, + })); + } + } + } + + if (command && command.trim()) { + // ACP `session/prompt` only accepts {sessionId, prompt}. Model and + // agent are baked into the spawn-time CLI flags above; resume mode + // inherits whatever the original session was created with. + const promptResult = await client.request('session/prompt', { + sessionId: capturedSessionId, + prompt: [{ type: 'text', text: command }], + }); + const stopReason = promptResult && typeof promptResult === 'object' + ? promptResult.stopReason + : null; + if (stopReason && stopReason !== 'end_turn') { + ws.send(createNormalizedMessage({ + kind: 'status', + status: stopReason, + sessionId: capturedSessionId, + provider: PROVIDER, + })); + } + } + + // Close stdin to let the child terminate naturally; the 'close' event + // handler above resolves the outer promise. + kiroProcess.stdin.end(); + } catch (rpcError) { + ws.send(createNormalizedMessage({ + kind: 'error', + content: rpcError instanceof Error ? rpcError.message : String(rpcError), + sessionId: capturedSessionId || sessionId || null, + provider: PROVIDER, + })); + // Half-close stdin so the child can drain pending writes, then SIGTERM. + // If the child ignores SIGTERM (running tool, blocked syscall), force + // SIGKILL after a grace window so the outer Promise resolves via the + // 'close' event handler instead of hanging forever. + try { kiroProcess.stdin.end(); } catch { /* already closed */ } + try { kiroProcess.kill('SIGTERM'); } catch { /* already gone */ } + setTimeout(() => { + if (!kiroProcess.killed) { + try { kiroProcess.kill('SIGKILL'); } catch { /* already gone */ } + } + }, 5000).unref(); + } + })(); + }); +} + +function abortKiroSession(sessionId) { + const process = activeKiroProcesses.get(sessionId); + if (process) { + console.log(`Aborting Kiro session: ${sessionId}`); + process.kill('SIGTERM'); + activeKiroProcesses.delete(sessionId); + return true; + } + return false; +} + +function isKiroSessionActive(sessionId) { + return activeKiroProcesses.has(sessionId); +} + +function getActiveKiroSessions() { + return Array.from(activeKiroProcesses.keys()); +} + +export { + spawnKiro, + abortKiroSession, + isKiroSessionActive, + getActiveKiroSessions, +}; diff --git a/server/modules/providers/list/kiro/__tests__/fixtures/error-result.jsonl b/server/modules/providers/list/kiro/__tests__/fixtures/error-result.jsonl new file mode 100644 index 0000000000..601bedcf06 --- /dev/null +++ b/server/modules/providers/list/kiro/__tests__/fixtures/error-result.jsonl @@ -0,0 +1,3 @@ +{"version":"v1","kind":"Prompt","data":{"message_id":"p1","content":[{"kind":"text","data":"please fail"}],"meta":{"timestamp":1778546781}}} +{"version":"v1","kind":"AssistantMessage","data":{"message_id":"a1","content":[{"kind":"toolUse","data":{"toolUseId":"tooluse_failing","name":"execute_bash","input":{"command":"false"}}}]}} +{"version":"v1","kind":"ToolResults","data":{"message_id":"r1","content":[{"kind":"toolResult","data":{"toolUseId":"tooluse_failing","content":[{"kind":"text","data":"command exited 1"}]}}],"status":"error"}} diff --git a/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.json b/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.json new file mode 100644 index 0000000000..106d8010c0 --- /dev/null +++ b/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.json @@ -0,0 +1,7 @@ +{ + "session_id": "9999aaaa-bbbb-cccc-dddd-000000000001", + "cwd": "/tmp", + "created_at": "2026-05-12T00:46:21.000Z", + "updated_at": "2026-05-12T00:46:30.000Z", + "title": "list the files in the current directory using fs_read, then in one sentence say what you saw" +} diff --git a/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.jsonl b/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.jsonl new file mode 100644 index 0000000000..2f4cd58d4d --- /dev/null +++ b/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.jsonl @@ -0,0 +1,4 @@ +{"version":"v1","kind":"Prompt","data":{"message_id":"a11875bb-ed17-4d3e-b2ba-71690a48a30a","content":[{"kind":"text","data":"list the files in the current directory using fs_read, then in one sentence say what you saw"}],"meta":{"timestamp":1778546781}}} +{"version":"v1","kind":"AssistantMessage","data":{"message_id":"605d7eb6-893b-496e-aa5b-74fc47526a46","content":[{"kind":"toolUse","data":{"toolUseId":"tooluse_hfZQ1jSPv5KV2JTruRda1F","name":"fs_read","input":{"operations":[{"mode":"Directory","path":"/tmp"}]}}}]}} +{"version":"v1","kind":"ToolResults","data":{"message_id":"599b78a0-161f-4227-9d0f-0ce96dafec19","content":[{"kind":"toolResult","data":{"toolUseId":"tooluse_hfZQ1jSPv5KV2JTruRda1F","content":[{"kind":"text","data":"User id: 1000\n-rw-r--r-- 1 1000 1000 100 May 12 /tmp/file"}]}}],"status":"success"}} +{"version":"v1","kind":"AssistantMessage","data":{"message_id":"02345062-d813-4d20-9cbf-02a3955036dc","content":[{"kind":"text","data":"The /tmp directory contains a single file."}]}} diff --git a/server/modules/providers/list/kiro/__tests__/kiro-auth.provider.test.ts b/server/modules/providers/list/kiro/__tests__/kiro-auth.provider.test.ts new file mode 100644 index 0000000000..c0ab29a858 --- /dev/null +++ b/server/modules/providers/list/kiro/__tests__/kiro-auth.provider.test.ts @@ -0,0 +1,129 @@ +/** + * Auth-detection tests for KiroProviderAuth. + * + * `kiro-cli whoami` is the source of truth for login state. These tests stub + * the CLI via a fake executable pointed to by KIRO_PATH and override $HOME so + * the legacy SSO token file (`~/.aws/sso/cache/kiro-auth-token.json`) can be + * present, absent, or expired independently of whoami. + * + * Regression: kiro-cli >= 2.7.0 no longer writes that token file, so keying + * auth off the file alone reported a logged-in user as expired. whoami must + * win, and a missing token file must not force "not authenticated". + */ +import { describe, it, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_USERPROFILE = process.env.USERPROFILE; +const ORIGINAL_KIRO_PATH = process.env.KIRO_PATH; + +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-auth-test-')); +const FAKE_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-auth-bin-')); +const FAKE_KIRO = path.join(FAKE_BIN_DIR, 'fake-kiro-cli.sh'); +const WHOAMI_OUTPUT_FILE = path.join(FAKE_BIN_DIR, 'whoami-output.txt'); +const TOKEN_PATH = path.join(TMP_HOME, '.aws', 'sso', 'cache', 'kiro-auth-token.json'); + +process.env.HOME = TMP_HOME; +process.env.USERPROFILE = TMP_HOME; +process.env.KIRO_PATH = FAKE_KIRO; + +// A tiny shell stub standing in for kiro-cli: `--version` exits 0; `whoami` +// prints whatever the current scenario wrote to WHOAMI_OUTPUT_FILE. +const FAKE_SCRIPT = `#!/bin/bash +case "$1" in + --version) echo "kiro-cli 9.9.9"; exit 0 ;; + whoami) cat "${WHOAMI_OUTPUT_FILE}" 2>/dev/null; exit 0 ;; + *) exit 0 ;; +esac +`; + +const setWhoami = (output: string): void => { + fs.writeFileSync(WHOAMI_OUTPUT_FILE, output); +}; + +const writeToken = (expiresAt: string, authMethod = 'IdC'): void => { + fs.mkdirSync(path.dirname(TOKEN_PATH), { recursive: true }); + fs.writeFileSync(TOKEN_PATH, JSON.stringify({ expiresAt, authMethod })); +}; + +const clearToken = (): void => { + fs.rmSync(TOKEN_PATH, { force: true }); +}; + +describe('KiroProviderAuth.getStatus', () => { + let KiroProviderAuth: typeof import('../kiro-auth.provider.js').KiroProviderAuth; + + before(async () => { + fs.writeFileSync(FAKE_KIRO, FAKE_SCRIPT, { mode: 0o755 }); + ({ KiroProviderAuth } = await import('../kiro-auth.provider.js')); + }); + + beforeEach(() => { + clearToken(); + setWhoami(''); + }); + + after(() => { + if (ORIGINAL_HOME === undefined) delete process.env.HOME; else process.env.HOME = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = ORIGINAL_USERPROFILE; + if (ORIGINAL_KIRO_PATH === undefined) delete process.env.KIRO_PATH; else process.env.KIRO_PATH = ORIGINAL_KIRO_PATH; + fs.rmSync(TMP_HOME, { recursive: true, force: true }); + fs.rmSync(FAKE_BIN_DIR, { recursive: true, force: true }); + }); + + it('authenticates from whoami alone when the legacy token file is absent (kiro-cli >= 2.7.0 regression)', async () => { + setWhoami('Logged in with IAM Identity Center (https://example.awsapps.com/start/)\nEmail: user@example.com\n'); + // No token file on disk — newer CLIs do not write kiro-auth-token.json. + + const status = await new KiroProviderAuth().getStatus(); + + assert.equal(status.authenticated, true); + assert.equal(status.email, 'user@example.com'); + assert.equal(status.method, 'IdC'); + assert.equal(status.error, undefined); + }); + + it('authenticates from whoami even when the legacy token file is expired', async () => { + setWhoami('Logged in with IAM Identity Center (https://example.awsapps.com/start/)\nEmail: user@example.com\n'); + writeToken('2000-01-01T00:00:00Z'); // long expired — must NOT override whoami + + const status = await new KiroProviderAuth().getStatus(); + + assert.equal(status.authenticated, true); + assert.equal(status.email, 'user@example.com'); + assert.equal(status.method, 'IdC'); + }); + + it('detects Builder ID method from whoami output', async () => { + setWhoami('Logged in with Builder ID\nEmail: builder@example.com\n'); + + const status = await new KiroProviderAuth().getStatus(); + + assert.equal(status.authenticated, true); + assert.equal(status.method, 'BuilderId'); + assert.equal(status.email, 'builder@example.com'); + }); + + it('reports not authenticated when whoami says not logged in', async () => { + setWhoami('Not logged in\n'); + + const status = await new KiroProviderAuth().getStatus(); + + assert.equal(status.authenticated, false); + assert.equal(status.email, null); + }); + + it('surfaces the expired-token error when logged out and a stale token explains why', async () => { + setWhoami('Not logged in\n'); + writeToken('2000-01-01T00:00:00Z', 'IdC'); + + const status = await new KiroProviderAuth().getStatus(); + + assert.equal(status.authenticated, false); + assert.equal(status.error, 'OAuth token has expired'); + assert.equal(status.method, 'IdC'); + }); +}); diff --git a/server/modules/providers/list/kiro/__tests__/kiro-mcp.provider.test.ts b/server/modules/providers/list/kiro/__tests__/kiro-mcp.provider.test.ts new file mode 100644 index 0000000000..13cce37585 --- /dev/null +++ b/server/modules/providers/list/kiro/__tests__/kiro-mcp.provider.test.ts @@ -0,0 +1,210 @@ +/** + * Real end-to-end MCP CRUD tests for KiroMcpProvider. + * + * Writes to a real on-disk JSON file under a tmp-dir override of $HOME and + * asserts the round-trip through `upsertServer` -> `listServersForScope`. + * Specifically validates the issue uncovered in code review: + * - `disabled` and `autoApprove` Kiro-specific fields are PRESERVED across + * upserts (they were being silently wiped). + * - HTTP transport with bearerTokenEnvVar is written and read back. + */ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// We intercept HOME to point at a fresh tmp dir so the provider writes there. +// Capture the original env vars (not os.homedir()) so teardown restores HOME +// and USERPROFILE independently — they are not guaranteed to match. +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_USERPROFILE = process.env.USERPROFILE; +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-mcp-test-')); + +// homedir() reads from the env on Linux, so override it before importing the +// provider (which captures path joins lazily inside instance methods). +process.env.HOME = TMP_HOME; +process.env.USERPROFILE = TMP_HOME; + +const { KiroMcpProvider } = await import('@/modules/providers/list/kiro/kiro-mcp.provider.js'); + +const SETTINGS_FILE = path.join(TMP_HOME, '.kiro', 'settings', 'mcp.json'); + +function writeSettings(content: Record): void { + fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true }); + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(content, null, 2)); +} + +function readSettings(): Record { + return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); +} + +describe('KiroMcpProvider', () => { + before(() => { + fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true }); + }); + + after(() => { + if (ORIGINAL_HOME !== undefined) { + process.env.HOME = ORIGINAL_HOME; + } else { + delete process.env.HOME; + } + if (ORIGINAL_USERPROFILE !== undefined) { + process.env.USERPROFILE = ORIGINAL_USERPROFILE; + } else { + delete process.env.USERPROFILE; + } + fs.rmSync(TMP_HOME, { recursive: true, force: true }); + }); + + it('lists existing user-scope stdio servers', async () => { + writeSettings({ + mcpServers: { + fetch: { + command: 'uvx', + args: ['mcp-server-fetch'], + env: { FOO: 'bar' }, + disabled: true, + autoApprove: ['*'], + }, + }, + }); + + const provider = new KiroMcpProvider(); + const list = await provider.listServersForScope('user'); + assert.equal(list.length, 1); + assert.equal(list[0].provider, 'kiro'); + assert.equal(list[0].name, 'fetch'); + assert.equal(list[0].transport, 'stdio'); + assert.equal(list[0].command, 'uvx'); + assert.deepEqual(list[0].args, ['mcp-server-fetch']); + assert.deepEqual(list[0].env, { FOO: 'bar' }); + }); + + it('preserves disabled=true on upsert (regression: user disable was being wiped)', async () => { + writeSettings({ + mcpServers: { + broken: { + command: 'old-bin', + args: [], + env: {}, + disabled: true, + autoApprove: ['risky_tool'], + }, + }, + }); + + const provider = new KiroMcpProvider(); + await provider.upsertServer({ + name: 'broken', + scope: 'user', + transport: 'stdio', + command: 'new-bin', // user is updating the binary path + args: ['--flag'], + env: { NEW: '1' }, + }); + + const after = readSettings(); + const broken = (after.mcpServers as Record>).broken; + + // New canonical fields took effect + assert.equal(broken.command, 'new-bin'); + assert.deepEqual(broken.args, ['--flag']); + assert.deepEqual(broken.env, { NEW: '1' }); + // CRITICAL: Kiro-only fields were preserved + assert.equal(broken.disabled, true); + assert.deepEqual(broken.autoApprove, ['risky_tool']); + }); + + it('preserves autoApprove without disabled', async () => { + writeSettings({ + mcpServers: { + partial: { + command: 'a', + args: [], + env: {}, + autoApprove: ['t1', 't2'], + }, + }, + }); + + const provider = new KiroMcpProvider(); + await provider.upsertServer({ + name: 'partial', + scope: 'user', + transport: 'stdio', + command: 'b', + args: [], + env: {}, + }); + + const after = readSettings(); + const partial = (after.mcpServers as Record>).partial; + assert.deepEqual(partial.autoApprove, ['t1', 't2']); + assert.equal(partial.disabled, undefined, 'must not invent a disabled field'); + }); + + it('writes bearer_token_env_var for HTTP transports', async () => { + writeSettings({ mcpServers: {} }); + + const provider = new KiroMcpProvider(); + await provider.upsertServer({ + name: 'token-protected', + scope: 'user', + transport: 'http', + url: 'https://example.com/mcp', + headers: { 'X-Custom': 'value' }, + bearerTokenEnvVar: 'MY_TOKEN', + }); + + const after = readSettings(); + const entry = (after.mcpServers as Record>)['token-protected']; + assert.equal(entry.url, 'https://example.com/mcp'); + assert.deepEqual(entry.headers, { 'X-Custom': 'value' }); + assert.equal(entry.bearer_token_env_var, 'MY_TOKEN'); + }); + + it('listServersForScope reads bearer_token_env_var back as bearerTokenEnvVar', async () => { + writeSettings({ + mcpServers: { + api: { + url: 'https://api.example.com/mcp', + headers: {}, + bearer_token_env_var: 'API_TOKEN', + }, + }, + }); + + const provider = new KiroMcpProvider(); + const list = await provider.listServersForScope('user'); + assert.equal(list.length, 1); + assert.equal(list[0].transport, 'http'); + assert.equal(list[0].url, 'https://api.example.com/mcp'); + assert.equal(list[0].bearerTokenEnvVar, 'API_TOKEN'); + }); + + it('removes a server', async () => { + writeSettings({ + mcpServers: { + keep: { command: 'a', args: [] }, + drop: { command: 'b', args: [] }, + }, + }); + + const provider = new KiroMcpProvider(); + const result = await provider.removeServer({ name: 'drop', scope: 'user' }); + assert.equal(result.removed, true); + + const after = readSettings(); + assert.deepEqual(Object.keys(after.mcpServers as object), ['keep']); + }); + + it('rejects unsupported scope (e.g. "local")', async () => { + const provider = new KiroMcpProvider(); + // KiroMcpProvider was constructed with ['user', 'project'] only; 'local' + // is a valid `McpScope` in the type system but unsupported at runtime. + const invalidInput = { name: 'x', scope: 'local', transport: 'stdio', command: 'whatever' } as Parameters[0]; + await assert.rejects(provider.upsertServer(invalidInput), (err: Error) => err.message.length > 0); + }); +}); diff --git a/server/modules/providers/list/kiro/__tests__/kiro-session-synchronizer.test.ts b/server/modules/providers/list/kiro/__tests__/kiro-session-synchronizer.test.ts new file mode 100644 index 0000000000..e577adac0f --- /dev/null +++ b/server/modules/providers/list/kiro/__tests__/kiro-session-synchronizer.test.ts @@ -0,0 +1,142 @@ +/** + * Real integration test for KiroSessionSynchronizer. + * + * Spins up an isolated SQLite DB + tmp ~/.kiro/sessions/cli/ tree, drops + * fixture {.json,.jsonl} pairs onto disk, runs `synchronizeFile`, and asserts + * the regression fix from code review: + * + * - When the existing DB row has a user-set `custom_name`, the next + * synchronizeFile pass MUST NOT overwrite it with the sidecar `title`. + * (Bug regression scenario: user renamed a session via the UI; next + * synchronization round-trip wiped the rename.) + */ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_USERPROFILE = process.env.USERPROFILE; +const ORIGINAL_DB = process.env.DATABASE_PATH; + +let tempHome: string; +let tempDbDir: string; + +before(async () => { + tempHome = await mkdtemp(path.join(tmpdir(), 'kiro-sync-home-')); + tempDbDir = await mkdtemp(path.join(tmpdir(), 'kiro-sync-db-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + process.env.DATABASE_PATH = path.join(tempDbDir, 'auth.db'); + + // mkdir the kiro session tree the synchronizer expects + await mkdir(path.join(tempHome, '.kiro', 'sessions', 'cli'), { recursive: true }); +}); + +after(async () => { + // Best-effort restore + cleanup. HOME and USERPROFILE are restored + // independently — they are not guaranteed to match (e.g. on Windows). + if (ORIGINAL_HOME !== undefined) { + process.env.HOME = ORIGINAL_HOME; + } else { + delete process.env.HOME; + } + if (ORIGINAL_USERPROFILE !== undefined) { + process.env.USERPROFILE = ORIGINAL_USERPROFILE; + } else { + delete process.env.USERPROFILE; + } + if (ORIGINAL_DB !== undefined) { + process.env.DATABASE_PATH = ORIGINAL_DB; + } else { + delete process.env.DATABASE_PATH; + } + if (tempHome) await rm(tempHome, { recursive: true, force: true }); + if (tempDbDir) await rm(tempDbDir, { recursive: true, force: true }); +}); + +describe('KiroSessionSynchronizer', () => { + it('preserves a user-set custom_name across re-synchronization (regression)', async () => { + // Lazy-import so the env overrides above are in effect when modules + // capture homedir() / DATABASE_PATH. + const { closeConnection, initializeDatabase, sessionsDb } = await import('@/modules/database/index.js'); + const { KiroSessionSynchronizer } = await import('@/modules/providers/list/kiro/kiro-session-synchronizer.provider.js'); + + closeConnection(); + await initializeDatabase(); + + const sessionId = 'aaaa1111-bbbb-cccc-dddd-000000000001'; + const sidecarPath = path.join(tempHome, '.kiro', 'sessions', 'cli', `${sessionId}.json`); + const jsonlPath = path.join(tempHome, '.kiro', 'sessions', 'cli', `${sessionId}.jsonl`); + + await writeFile( + sidecarPath, + JSON.stringify({ + session_id: sessionId, + cwd: '/tmp', + created_at: '2026-05-12T00:00:00.000Z', + updated_at: '2026-05-12T00:00:30.000Z', + title: 'original kiro-derived title', + }), + ); + await writeFile(jsonlPath, '{"version":"v1","kind":"Prompt","data":{"message_id":"p1","content":[{"kind":"text","data":"hi"}]}}\n'); + + const sync = new KiroSessionSynchronizer(); + + // First sync: no DB row exists → adopt the sidecar title. + await sync.synchronizeFile(jsonlPath); + let row = sessionsDb.getSessionById(sessionId); + assert.ok(row, 'session should be indexed after first sync'); + assert.equal(row.custom_name, 'original kiro-derived title'); + assert.equal(row.provider, 'kiro'); + assert.equal(row.project_path, '/tmp'); + + // User renames the session via the UI. + sessionsDb.updateSessionCustomName(sessionId, 'My Important Refactor'); + row = sessionsDb.getSessionById(sessionId); + assert.equal(row?.custom_name, 'My Important Refactor'); + + // Second sync (e.g. a watcher event after the user typed another message). + // The sidecar `title` is still "original kiro-derived title", but the user's + // custom name MUST survive. + await sync.synchronizeFile(jsonlPath); + row = sessionsDb.getSessionById(sessionId); + assert.equal( + row?.custom_name, + 'My Important Refactor', + 'user-set custom_name must not be wiped by re-synchronization', + ); + + closeConnection(); + }); + + it('skips files whose sidecar JSON is missing', async () => { + const { closeConnection, initializeDatabase, sessionsDb } = await import('@/modules/database/index.js'); + const { KiroSessionSynchronizer } = await import('@/modules/providers/list/kiro/kiro-session-synchronizer.provider.js'); + + closeConnection(); + await initializeDatabase(); + + const sessionId = 'no-sidecar-' + Date.now(); + const jsonlPath = path.join(tempHome, '.kiro', 'sessions', 'cli', `${sessionId}.jsonl`); + // jsonl exists, sidecar does NOT (race window when ACP creates jsonl first) + await writeFile(jsonlPath, '{"version":"v1","kind":"Prompt","data":{"content":[]}}\n'); + + const sync = new KiroSessionSynchronizer(); + const result = await sync.synchronizeFile(jsonlPath); + + assert.equal(result, null, 'must return null when sidecar is missing'); + const row = sessionsDb.getSessionById(sessionId); + assert.ok(row == null, 'must not insert a row without project_path'); + + closeConnection(); + }); + + it('returns null for non-jsonl paths', async () => { + const { KiroSessionSynchronizer } = await import('@/modules/providers/list/kiro/kiro-session-synchronizer.provider.js'); + const sync = new KiroSessionSynchronizer(); + const result = await sync.synchronizeFile('/some/path/foo.json'); + assert.equal(result, null); + }); +}); diff --git a/server/modules/providers/list/kiro/__tests__/kiro-sessions.provider.test.ts b/server/modules/providers/list/kiro/__tests__/kiro-sessions.provider.test.ts new file mode 100644 index 0000000000..53c644220e --- /dev/null +++ b/server/modules/providers/list/kiro/__tests__/kiro-sessions.provider.test.ts @@ -0,0 +1,242 @@ +/** + * Real tests for KiroSessionsProvider. + * + * Exercises normalizeMessage against verified Kiro JSONL event shapes (Prompt, + * AssistantMessage with text + toolUse mix, ToolResults with status:'success' + * vs status:'error'). Fixture data was captured from a real `kiro-cli acp` + * session against `/tmp` on 2026-05-12. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { KiroSessionsProvider } from '@/modules/providers/list/kiro/kiro-sessions.provider.js'; + +const PROVIDER = 'kiro'; + +describe('KiroSessionsProvider.normalizeMessage', () => { + const provider = new KiroSessionsProvider(); + + it('normalizes a Prompt entry to a single user text message', () => { + const entry = { + version: 'v1', + kind: 'Prompt', + data: { + message_id: 'p1', + content: [{ kind: 'text', data: 'hello world' }], + meta: { timestamp: 1778546781 }, + }, + }; + + const messages = provider.normalizeMessage(entry, 'session-1'); + assert.equal(messages.length, 1); + assert.equal(messages[0].kind, 'text'); + assert.equal(messages[0].role, 'user'); + assert.equal(messages[0].content, 'hello world'); + assert.equal(messages[0].provider, PROVIDER); + assert.equal(messages[0].sessionId, 'session-1'); + // meta.timestamp is unix seconds; we should produce ISO 8601 + assert.match(messages[0].timestamp, /^2026-/); + }); + + it('normalizes an AssistantMessage with text + toolUse content to two messages', () => { + const entry = { + version: 'v1', + kind: 'AssistantMessage', + data: { + message_id: 'a1', + content: [ + { kind: 'text', data: 'I will call a tool now.' }, + { + kind: 'toolUse', + data: { + toolUseId: 'tool-call-1', + name: 'fs_read', + input: { operations: [{ mode: 'Directory', path: '/tmp' }] }, + }, + }, + ], + }, + }; + + const messages = provider.normalizeMessage(entry, 'session-1'); + assert.equal(messages.length, 2); + + assert.equal(messages[0].kind, 'text'); + assert.equal(messages[0].role, 'assistant'); + assert.equal(messages[0].content, 'I will call a tool now.'); + + assert.equal(messages[1].kind, 'tool_use'); + assert.equal(messages[1].toolName, 'fs_read'); + assert.equal(messages[1].toolId, 'tool-call-1'); + assert.deepEqual(messages[1].toolInput, { operations: [{ mode: 'Directory', path: '/tmp' }] }); + }); + + it('marks ToolResults with status="error" as isError=true', () => { + const entry = { + version: 'v1', + kind: 'ToolResults', + data: { + message_id: 'r1', + status: 'error', + content: [ + { + kind: 'toolResult', + data: { + toolUseId: 'tool-call-1', + content: [{ kind: 'text', data: 'command exited 1' }], + }, + }, + ], + }, + }; + + const messages = provider.normalizeMessage(entry, 'session-1'); + assert.equal(messages.length, 1); + assert.equal(messages[0].kind, 'tool_result'); + assert.equal(messages[0].isError, true); + assert.equal(messages[0].content, 'command exited 1'); + assert.equal(messages[0].toolId, 'tool-call-1'); + }); + + it('marks ToolResults with status="success" as isError=false', () => { + const entry = { + version: 'v1', + kind: 'ToolResults', + data: { + message_id: 'r2', + status: 'success', + content: [ + { + kind: 'toolResult', + data: { + toolUseId: 'tool-call-2', + content: [{ kind: 'text', data: 'ok' }], + }, + }, + ], + }, + }; + + const messages = provider.normalizeMessage(entry, 'session-1'); + assert.equal(messages.length, 1); + assert.equal(messages[0].isError, false); + }); + + it('falls back to per-content error kinds when status is missing', () => { + const entry = { + version: 'v1', + kind: 'ToolResults', + data: { + message_id: 'r3', + // no status field — exercise the content-part fallback + content: [ + { + kind: 'toolResult', + data: { + toolUseId: 'tool-call-3', + content: [{ kind: 'errorText', data: 'something went wrong' }], + }, + }, + ], + }, + }; + + const messages = provider.normalizeMessage(entry, 'session-1'); + assert.equal(messages.length, 1); + assert.equal(messages[0].isError, true); + assert.equal(messages[0].content, 'something went wrong'); + }); + + it('drops Prompt entries with empty/whitespace content', () => { + const entry = { + version: 'v1', + kind: 'Prompt', + data: { + message_id: 'p2', + content: [{ kind: 'text', data: ' \n\t' }], + meta: { timestamp: 1778546781 }, + }, + }; + + const messages = provider.normalizeMessage(entry, 'session-1'); + assert.equal(messages.length, 0); + }); + + it('returns [] for unknown kind', () => { + const entry = { + version: 'v1', + kind: 'UnknownEventType', + data: { message_id: 'x' }, + }; + + assert.equal(provider.normalizeMessage(entry, 'session-1').length, 0); + }); + + it('returns [] for non-object input', () => { + assert.equal(provider.normalizeMessage(null, 'session-1').length, 0); + assert.equal(provider.normalizeMessage('string', 'session-1').length, 0); + assert.equal(provider.normalizeMessage(42, 'session-1').length, 0); + }); + + it('produces unique ids when an entry has multiple text or tool parts', () => { + // Regression: when CodeRabbit flagged this, an AssistantMessage with two + // text parts produced two messages with the same `id` (both `${baseId}_text`), + // breaking React keyed rendering and message-association lookups. + const entry = { + version: 'v1', + kind: 'AssistantMessage', + data: { + message_id: 'a-multi', + content: [ + { kind: 'text', data: 'first chunk' }, + { kind: 'text', data: 'second chunk' }, + { + kind: 'toolUse', + data: { toolUseId: 'tu1', name: 'fs_read', input: { path: '/a' } }, + }, + { + kind: 'toolUse', + data: { toolUseId: 'tu2', name: 'fs_read', input: { path: '/b' } }, + }, + ], + }, + }; + + const messages = provider.normalizeMessage(entry, 'session-1'); + const ids = messages.map((m) => m.id); + assert.equal(messages.length, 4); + assert.equal(new Set(ids).size, 4, `all ids must be unique; got: ${ids.join(', ')}`); + }); + + it('produces unique ids when ToolResults has multiple toolResult parts', () => { + const entry = { + version: 'v1', + kind: 'ToolResults', + data: { + message_id: 'r-multi', + status: 'success', + content: [ + { + kind: 'toolResult', + data: { + toolUseId: 'shared-tool-id', + content: [{ kind: 'text', data: 'first result' }], + }, + }, + { + kind: 'toolResult', + data: { + toolUseId: 'shared-tool-id', + content: [{ kind: 'text', data: 'second result' }], + }, + }, + ], + }, + }; + + const messages = provider.normalizeMessage(entry, 'session-1'); + const ids = messages.map((m) => m.id); + assert.equal(messages.length, 2); + assert.equal(new Set(ids).size, 2, `tool_result ids must differ even when toolUseId collides; got: ${ids.join(', ')}`); + }); +}); diff --git a/server/modules/providers/list/kiro/__tests__/stdio-jsonrpc-client.test.ts b/server/modules/providers/list/kiro/__tests__/stdio-jsonrpc-client.test.ts new file mode 100644 index 0000000000..82b2daf976 --- /dev/null +++ b/server/modules/providers/list/kiro/__tests__/stdio-jsonrpc-client.test.ts @@ -0,0 +1,310 @@ +/** + * Real unit tests for StdioJsonRpcClient. + * + * Backs the JSON-RPC 2.0 wire protocol against an in-process child substitute: + * a Node Duplex acting as the child's stdio pair so we can drive byte streams + * exactly like the real ChildProcessWithoutNullStreams. No mocks, no spies — + * the assertions exercise the real transport state machine. + * + * Run with `node --test --import tsx`. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; + +import { StdioJsonRpcClient } from '@/modules/providers/list/kiro/stdio-jsonrpc-client.js'; + +// Build a minimal stand-in matching the surface area StdioJsonRpcClient uses: +// stdin (writable), stdout (readable), stderr (readable), and the EE for +// 'close' / 'error'. The class never touches any other ChildProcess fields. +function makeFakeChild(): { + child: ChildProcessWithoutNullStreams; + emitStdout: (chunk: string) => void; + emitStderr: (chunk: string) => void; + emitClose: (code: number | null) => void; + emitError: (err: Error) => void; + stdinWrites: string[]; +} { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const stdinWrites: string[] = []; + const stdin = { + write(chunk: string, callback?: (err?: Error | null) => void): boolean { + stdinWrites.push(chunk); + callback?.(null); + return true; + }, + end(): void {}, + }; + + const ee = new EventEmitter(); + const fake = Object.assign(ee, { + stdin, + stdout, + stderr, + killed: false, + }); + + return { + child: fake as unknown as ChildProcessWithoutNullStreams, + emitStdout: (chunk: string) => stdout.write(chunk), + emitStderr: (chunk: string) => stderr.write(chunk), + emitClose: (code: number | null) => ee.emit('close', code), + emitError: (err: Error) => ee.emit('error', err), + stdinWrites, + }; +} + +describe('StdioJsonRpcClient', () => { + it('correlates request id to response result', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + + const promise = client.request<{ value: number }>('test/method', { foo: 1 }); + + // Verify the wire format we produced + assert.equal(fake.stdinWrites.length, 1); + const sent = JSON.parse(fake.stdinWrites[0].trim()); + assert.equal(sent.jsonrpc, '2.0'); + assert.equal(sent.method, 'test/method'); + assert.deepEqual(sent.params, { foo: 1 }); + assert.equal(typeof sent.id, 'number'); + + // Send back a matching response + fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: { value: 42 } })}\n`); + + const result = await promise; + assert.deepEqual(result, { value: 42 }); + }); + + it('rejects on JSON-RPC error frame with the error message', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + + const promise = client.request('failing/method'); + const sent = JSON.parse(fake.stdinWrites[0].trim()); + + fake.emitStdout( + `${JSON.stringify({ + jsonrpc: '2.0', + id: sent.id, + error: { code: -32603, message: 'Internal error', data: { detail: 'kiro broke' } }, + })}\n`, + ); + + await assert.rejects(promise, (err: Error) => { + assert.match(err.message, /Internal error/); + assert.match(err.message, /failing\/method/); + return true; + }); + }); + + it('handles a frame split across multiple stdout chunks', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + + const promise = client.request('split/test'); + const sent = JSON.parse(fake.stdinWrites[0].trim()); + const fullFrame = JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: 'ok' }) + '\n'; + + // Split mid-string across 4 chunks + fake.emitStdout(fullFrame.slice(0, 5)); + fake.emitStdout(fullFrame.slice(5, 20)); + fake.emitStdout(fullFrame.slice(20, 35)); + fake.emitStdout(fullFrame.slice(35)); + + assert.equal(await promise, 'ok'); + }); + + it('handles multiple frames in one chunk', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + + const p1 = client.request('a'); + const p2 = client.request('b'); + const id1 = JSON.parse(fake.stdinWrites[0]).id; + const id2 = JSON.parse(fake.stdinWrites[1]).id; + + const combined = + JSON.stringify({ jsonrpc: '2.0', id: id1, result: 'first' }) + '\n' + + JSON.stringify({ jsonrpc: '2.0', id: id2, result: 'second' }) + '\n'; + fake.emitStdout(combined); + + assert.equal(await p1, 'first'); + assert.equal(await p2, 'second'); + }); + + it('skips empty lines and CRLF without crashing', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + + const p = client.request('crlf/test'); + const sent = JSON.parse(fake.stdinWrites[0]); + + fake.emitStdout('\n\r\n \n'); + fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: 'ok' })}\r\n`); + + assert.equal(await p, 'ok'); + }); + + it('routes notifications to exact-method handlers', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + + let received: unknown = null; + client.onNotification('session/update', (params) => { + received = params; + }); + + fake.emitStdout(`${JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { sessionId: 'abc', update: { sessionUpdate: 'agent_message_chunk' } }, + })}\n`); + + // Allow microtask queue to flush + await new Promise((r) => setImmediate(r)); + + assert.deepEqual(received, { sessionId: 'abc', update: { sessionUpdate: 'agent_message_chunk' } }); + }); + + it('routes prefixed notifications to wildcard handlers', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + + const seen: Array<{ method: string; params: unknown }> = []; + client.onNotificationPrefix('_kiro.dev/', (params) => { + seen.push({ method: 'prefix-match', params }); + }); + + fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', method: '_kiro.dev/metadata', params: { x: 1 } })}\n`); + fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', method: '_kiro.dev/mcp/server_initialized', params: { y: 2 } })}\n`); + fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: { z: 3 } })}\n`); + + await new Promise((r) => setImmediate(r)); + + // Only the two _kiro.dev/* notifications hit the prefix handler + assert.equal(seen.length, 2); + assert.deepEqual(seen.map((s) => s.params), [{ x: 1 }, { y: 2 }]); + }); + + it('rejects all pending requests on child close', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + + const p1 = client.request('a').catch((e: Error) => e); + const p2 = client.request('b').catch((e: Error) => e); + + fake.emitClose(1); + + const [e1, e2] = await Promise.all([p1, p2]); + assert.match((e1 as Error).message, /closed/); + assert.match((e2 as Error).message, /closed/); + }); + + it('rejects new requests after close synchronously', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + fake.emitClose(0); + + await assert.rejects(client.request('after-close'), (err: Error) => { + assert.match(err.message, /closed/); + return true; + }); + }); + + it('survives non-JSON stdout lines without crashing', async () => { + const fake = makeFakeChild(); + let parseErrors = 0; + const client = new StdioJsonRpcClient(fake.child, { + onParseError: () => { parseErrors += 1; }, + }); + + const p = client.request('survive/test'); + const sent = JSON.parse(fake.stdinWrites[0]); + + // First a stderr leak that landed on stdout (not JSON), then a valid frame + fake.emitStdout('warning: something is up on stderr but emitted on stdout\n'); + fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: 'still works' })}\n`); + + assert.equal(await p, 'still works'); + assert.equal(parseErrors, 1); + }); + + it('forwards stderr lines to onStderr callback', async () => { + const fake = makeFakeChild(); + const stderrLines: string[] = []; + new StdioJsonRpcClient(fake.child, { onStderr: (l) => stderrLines.push(l) }); + + fake.emitStderr('first error\nsecond error\n'); + await new Promise((r) => setImmediate(r)); + + assert.deepEqual(stderrLines, ['first error', 'second error']); + }); + + it('rejects request that times out', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child, { requestTimeoutMs: 50 }); + + await assert.rejects(client.request('slow/method'), (err: Error) => { + assert.match(err.message, /timed out/); + assert.match(err.message, /slow\/method/); + return true; + }); + }); + + it('notify() does not expect a response and never resolves a promise', () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + + client.notify('session/cancel', { sessionId: 'abc' }); + + assert.equal(fake.stdinWrites.length, 1); + const sent = JSON.parse(fake.stdinWrites[0].trim()); + assert.equal(sent.jsonrpc, '2.0'); + assert.equal(sent.method, 'session/cancel'); + assert.equal('id' in sent, false, 'notification frames must have no id'); + }); + + it('continues processing frames when a notification handler throws', async () => { + const fake = makeFakeChild(); + const client = new StdioJsonRpcClient(fake.child); + + let goodCalls = 0; + client.onNotification('throws', () => { + throw new Error('handler boom'); + }); + client.onNotification('survives', () => { + goodCalls += 1; + }); + + // Suppress the expected console.error so it doesn't pollute test output; + // we still want to assert the stream itself kept flowing. + const originalConsoleError = console.error; + const errorCalls: unknown[][] = []; + console.error = (...args) => { + errorCalls.push(args); + }; + + try { + // Three frames: throwing handler, recoverable handler, then a request + // response that proves the dispatch loop wasn't broken. + const promise = client.request('after-throw'); + const sent = JSON.parse(fake.stdinWrites[0].trim()); + + fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', method: 'throws', params: {} })}\n`); + fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', method: 'survives', params: {} })}\n`); + fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: 'still works' })}\n`); + + assert.equal(await promise, 'still works'); + // Allow the synchronous notification handlers to finish + await new Promise((r) => setImmediate(r)); + assert.equal(goodCalls, 1); + assert.ok(errorCalls.length >= 1, 'handler errors should be logged'); + } finally { + console.error = originalConsoleError; + } + }); +}); diff --git a/server/modules/providers/list/kiro/kiro-auth.provider.ts b/server/modules/providers/list/kiro/kiro-auth.provider.ts new file mode 100644 index 0000000000..77ccc500d8 --- /dev/null +++ b/server/modules/providers/list/kiro/kiro-auth.provider.ts @@ -0,0 +1,205 @@ +import { readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import spawn from 'cross-spawn'; + +import type { IProviderAuth } from '@/shared/interfaces.js'; +import type { ProviderAuthStatus } from '@/shared/types.js'; +import { readObjectRecord, readOptionalString } from '@/shared/utils.js'; + +type KiroCredentialsStatus = { + authenticated: boolean; + email: string | null; + method: string | null; + error?: string; +}; + +const KIRO_BIN = process.env.KIRO_PATH ?? 'kiro-cli'; + +export class KiroProviderAuth implements IProviderAuth { + /** + * Checks whether the Kiro CLI is installed and on PATH. + * + * Spawns asynchronously (rather than spawn.sync) so a slow or hung CLI cannot + * block the Node event loop while `getStatus()` runs; the process is killed if + * `--version` does not return within the timeout. + */ + private checkInstalled(): Promise { + return new Promise((resolve) => { + let settled = false; + let childProcess: ReturnType | undefined; + + const finish = (value: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolve(value); + }; + + const timeout = setTimeout(() => { + childProcess?.kill(); + finish(false); + }, 5000); + + try { + childProcess = spawn(KIRO_BIN, ['--version'], { stdio: 'ignore' }); + } catch { + finish(false); + return; + } + + childProcess.on('close', (code) => finish(code === 0)); + childProcess.on('error', () => finish(false)); + }); + } + + /** + * Returns Kiro CLI installation and IdC/BuilderId login status. + */ + async getStatus(): Promise { + const installed = await this.checkInstalled(); + + if (!installed) { + return { + installed, + provider: 'kiro', + authenticated: false, + email: null, + method: null, + error: 'Kiro CLI is not installed', + }; + } + + const credentials = await this.checkCredentials(); + + return { + installed, + provider: 'kiro', + authenticated: credentials.authenticated, + email: credentials.email, + method: credentials.method, + error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated', + }; + } + + /** + * Resolves login state from `kiro-cli whoami`, which is the source of truth + * across CLI versions. + * + * `whoami` is authoritative because the on-disk token location is not stable: + * kiro-cli <= 2.3.0 wrote `~/.aws/sso/cache/kiro-auth-token.json`, but later + * versions (verified on 2.7.0) write hashed-filename token files instead, so + * keying auth off that single path reports a logged-in user as expired. The + * token file is now only a best-effort hint used to enrich the method label + * and surface an explicit "expired" error; it never overrides whoami. + */ + private async checkCredentials(): Promise { + const whoami = await this.readWhoami(); + const tokenHint = await this.readTokenHint(); + + if (whoami.loggedIn) { + return { + authenticated: true, + email: whoami.email ?? 'Authenticated', + method: whoami.method ?? tokenHint.method ?? 'sso', + }; + } + + // whoami says not logged in: prefer a precise "expired" error when the + // stale token file still explains why, otherwise a generic message. + return { + authenticated: false, + email: null, + method: whoami.method ?? tokenHint.method, + error: tokenHint.expired ? 'OAuth token has expired' : 'Not authenticated', + }; + } + + /** + * Best-effort read of the legacy SSO cache token for method/expiry hints. + * + * Returns empty hints when the file is absent (expected on newer CLIs) so a + * missing file never forces a not-authenticated result on its own. + */ + private async readTokenHint(): Promise<{ method: string | null; expired: boolean }> { + try { + const tokenPath = path.join(os.homedir(), '.aws', 'sso', 'cache', 'kiro-auth-token.json'); + const content = await readFile(tokenPath, 'utf8'); + const token = readObjectRecord(JSON.parse(content)) ?? {}; + const method = readOptionalString(token.authMethod) ?? null; + const expiresAt = readOptionalString(token.expiresAt); + const expiryMs = expiresAt ? Date.parse(expiresAt) : NaN; + const expired = Number.isFinite(expiryMs) ? expiryMs <= Date.now() : false; + return { method, expired }; + } catch { + return { method: null, expired: false }; + } + } + + /** + * Runs `kiro-cli whoami` and parses login state, email, and auth method. + * + * Uses the plain (default) output format for cross-version compatibility + * (the `--format json` flag does not exist on older CLIs). Stdout shapes: + * Logged in with IAM Identity Center (https://...) -> method 'IdC' + * Logged in with Builder ID -> method 'BuilderId' + * Email: someone@example.com + * Not logged in -> loggedIn false + * + * `whoami` exits 0 in both states, so login is determined from the text, not + * the exit code. + */ + private readWhoami(): Promise<{ loggedIn: boolean; email: string | null; method: string | null }> { + return new Promise((resolve) => { + let processCompleted = false; + let childProcess: ReturnType | undefined; + + const finish = (value: { loggedIn: boolean; email: string | null; method: string | null }) => { + if (processCompleted) { + return; + } + processCompleted = true; + clearTimeout(timeout); + resolve(value); + }; + + const timeout = setTimeout(() => { + childProcess?.kill(); + finish({ loggedIn: false, email: null, method: null }); + }, 5000); + + try { + childProcess = spawn(KIRO_BIN, ['whoami']); + } catch { + finish({ loggedIn: false, email: null, method: null }); + return; + } + + let stdout = ''; + childProcess.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + childProcess.on('close', () => { + const emailMatch = stdout.match(/Email:\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); + const loggedIn = /logged in with/i.test(stdout) || emailMatch !== null; + let method: string | null = null; + if (/identity center/i.test(stdout)) { + method = 'IdC'; + } else if (/builder\s*id/i.test(stdout)) { + method = 'BuilderId'; + } else if (loggedIn) { + method = 'sso'; + } + finish({ loggedIn, email: emailMatch?.[1] ?? null, method }); + }); + + childProcess.on('error', () => { + finish({ loggedIn: false, email: null, method: null }); + }); + }); + } +} diff --git a/server/modules/providers/list/kiro/kiro-mcp.provider.ts b/server/modules/providers/list/kiro/kiro-mcp.provider.ts new file mode 100644 index 0000000000..69f0b12881 --- /dev/null +++ b/server/modules/providers/list/kiro/kiro-mcp.provider.ts @@ -0,0 +1,163 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js'; +import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; +import { + AppError, + readJsonConfig, + readObjectRecord, + readOptionalString, + readStringArray, + readStringRecord, + writeJsonConfig, +} from '@/shared/utils.js'; + +const SETTINGS_DIR = path.join('.kiro', 'settings'); + +export class KiroMcpProvider extends McpProvider { + constructor() { + super('kiro', ['user', 'project'], ['stdio', 'http']); + } + + protected async readScopedServers(scope: McpScope, workspacePath: string): Promise> { + const filePath = scope === 'user' + ? path.join(os.homedir(), SETTINGS_DIR, 'mcp.json') + : path.join(workspacePath, SETTINGS_DIR, 'mcp.json'); + const config = await readJsonConfig(filePath); + return readObjectRecord(config.mcpServers) ?? {}; + } + + protected async writeScopedServers( + scope: McpScope, + workspacePath: string, + servers: Record, + ): Promise { + const filePath = scope === 'user' + ? path.join(os.homedir(), SETTINGS_DIR, 'mcp.json') + : path.join(workspacePath, SETTINGS_DIR, 'mcp.json'); + const config = await readJsonConfig(filePath); + config.mcpServers = servers; + await writeJsonConfig(filePath, config); + } + + protected buildServerConfig(input: UpsertProviderMcpServerInput): Record { + if (input.transport === 'stdio') { + if (!input.command?.trim()) { + throw new AppError('command is required for stdio MCP servers.', { + code: 'MCP_COMMAND_REQUIRED', + statusCode: 400, + }); + } + + return { + command: input.command, + args: input.args ?? [], + env: input.env ?? {}, + }; + } + + if (!input.url?.trim()) { + throw new AppError('url is required for http MCP servers.', { + code: 'MCP_URL_REQUIRED', + statusCode: 400, + }); + } + + const httpConfig: Record = { + url: input.url, + headers: input.headers ?? {}, + }; + if (input.bearerTokenEnvVar) { + httpConfig.bearer_token_env_var = input.bearerTokenEnvVar; + } + return httpConfig; + } + + protected normalizeServerConfig( + scope: McpScope, + name: string, + rawConfig: unknown, + ): ProviderMcpServer | null { + if (!rawConfig || typeof rawConfig !== 'object') { + return null; + } + + const config = rawConfig as Record; + if (typeof config.command === 'string') { + return { + provider: 'kiro', + name, + scope, + transport: 'stdio', + command: config.command, + args: readStringArray(config.args), + env: readStringRecord(config.env), + }; + } + + if (typeof config.url === 'string') { + return { + provider: 'kiro', + name, + scope, + transport: 'http', + url: config.url, + headers: readStringRecord(config.headers), + bearerTokenEnvVar: readOptionalString(config.bearer_token_env_var), + }; + } + + return null; + } + + /** + * Override the base `upsertServer` to preserve Kiro-specific fields that the + * provider-neutral `UpsertProviderMcpServerInput` does not carry: `disabled` + * (per-server enabled/disabled toggle) and `autoApprove` (whitelist of tool + * names the user has pre-trusted). Without this override, every UI edit + * silently re-enables disabled servers and wipes auto-approve lists. + * + * Strategy: read the existing entry BEFORE `super.upsertServer` rewrites the + * file, then re-apply the preserved Kiro-only keys with a follow-up write. + */ + async upsertServer(input: UpsertProviderMcpServerInput): Promise { + const scope = input.scope ?? 'project'; + const workspacePath = scope === 'user' + ? os.homedir() + : (input.workspacePath ?? ''); + const filePath = scope === 'user' + ? path.join(os.homedir(), SETTINGS_DIR, 'mcp.json') + : path.join(workspacePath, SETTINGS_DIR, 'mcp.json'); + + // Capture Kiro-only fields BEFORE the base class wipes them on rewrite. + const preWriteConfig = await readJsonConfig(filePath); + const preWriteServers = readObjectRecord(preWriteConfig.mcpServers) ?? {}; + const preWriteEntry = readObjectRecord(preWriteServers[input.name]); + const preservedDisabled = preWriteEntry + ? (preWriteEntry as Record).disabled + : undefined; + const preservedAutoApprove = preWriteEntry + ? (preWriteEntry as Record).autoApprove + : undefined; + + const result = await super.upsertServer(input); + + if (preservedDisabled !== undefined || preservedAutoApprove !== undefined) { + const config = await readJsonConfig(filePath); + const servers = readObjectRecord(config.mcpServers) ?? {}; + const updated = readObjectRecord(servers[input.name]) ?? {}; + if (preservedDisabled !== undefined) { + updated.disabled = preservedDisabled; + } + if (preservedAutoApprove !== undefined) { + updated.autoApprove = preservedAutoApprove; + } + servers[input.name] = updated; + config.mcpServers = servers; + await writeJsonConfig(filePath, config); + } + + return result; + } +} diff --git a/server/modules/providers/list/kiro/kiro-models.provider.ts b/server/modules/providers/list/kiro/kiro-models.provider.ts new file mode 100644 index 0000000000..cfb51d72a5 --- /dev/null +++ b/server/modules/providers/list/kiro/kiro-models.provider.ts @@ -0,0 +1,51 @@ +import type { IProviderModels } from '@/shared/interfaces.js'; +import type { + ProviderChangeActiveModelInput, + ProviderCurrentActiveModel, + ProviderModelsDefinition, + ProviderSessionActiveModelChange, +} from '@/shared/types.js'; +import { + buildDefaultProviderCurrentActiveModel, + writeProviderSessionActiveModelChange, +} from '@/shared/utils.js'; + +/** + * Kiro (AWS) model catalog. + * + * Verified against `kiro-cli chat --list-models -f json` (Kiro CLI 2.3.0). The + * full catalog can be fetched dynamically, but the static list below is a + * conservative Claude-only subset suitable for v1. The `auto` router default + * lets Kiro pick the best available model for the request. + */ +export const KIRO_FALLBACK_MODELS: ProviderModelsDefinition = { + OPTIONS: [ + { value: 'auto', label: 'Auto (router)' }, + { value: 'claude-opus-4.7', label: 'Claude Opus 4.7' }, + { value: 'claude-opus-4.6', label: 'Claude Opus 4.6' }, + { value: 'claude-sonnet-4.6', label: 'Claude Sonnet 4.6' }, + { value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' }, + { value: 'claude-haiku-4.5', label: 'Claude Haiku 4.5' }, + ], + DEFAULT: 'auto', +}; + +export class KiroProviderModels implements IProviderModels { + async getSupportedModels(): Promise { + return KIRO_FALLBACK_MODELS; + } + + async getCurrentActiveModel(): Promise { + // Kiro resolves the effective model at spawn time via the `--model` CLI + // flag (ACP ignores model on `session/prompt`). There is no persisted + // per-session model on disk to read back, so fall back to the catalog + // default. + return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels()); + } + + async changeActiveModel( + input: ProviderChangeActiveModelInput, + ): Promise { + return writeProviderSessionActiveModelChange('kiro', input); + } +} diff --git a/server/modules/providers/list/kiro/kiro-session-synchronizer.provider.ts b/server/modules/providers/list/kiro/kiro-session-synchronizer.provider.ts new file mode 100644 index 0000000000..dae07dadbb --- /dev/null +++ b/server/modules/providers/list/kiro/kiro-session-synchronizer.provider.ts @@ -0,0 +1,133 @@ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import { readFile } from 'node:fs/promises'; + +import { sessionsDb } from '@/modules/database/index.js'; +import { + findFilesRecursivelyCreatedAfter, + normalizeSessionName, + readFileTimestamps, + readObjectRecord, + readOptionalString, +} from '@/shared/utils.js'; +import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; + +type ParsedSession = { + sessionId: string; + projectPath: string; + sessionName?: string; +}; + +const UNTITLED = 'Untitled Kiro Session'; + +/** + * Session indexer for Kiro CLI ACP transcripts. + * + * Kiro persists each ACP session as a pair under `~/.kiro/sessions/cli/`: + * .jsonl — append-only event log (Prompt/AssistantMessage/ToolResults) + * .json — sidecar with `{session_id, cwd, created_at, updated_at, title, ...}` + * + * The sidecar is the cheaper, more reliable source for project path and + * display title; the JSONL is parsed by `KiroSessionsProvider.fetchHistory`. + */ +export class KiroSessionSynchronizer implements IProviderSessionSynchronizer { + private readonly provider = 'kiro' as const; + private readonly sessionsRoot = path.join(os.homedir(), '.kiro', 'sessions', 'cli'); + + /** + * Scans `~/.kiro/sessions/cli/*.jsonl` and upserts discovered sessions. + */ + async synchronize(since?: Date): Promise { + if (!fs.existsSync(this.sessionsRoot)) { + return 0; + } + + const files = await findFilesRecursivelyCreatedAfter( + this.sessionsRoot, + '.jsonl', + since ?? null, + ); + + let processed = 0; + for (const filePath of files) { + const upserted = await this.synchronizeFile(filePath); + if (upserted) { + processed += 1; + } + } + + return processed; + } + + /** + * Parses one JSONL session file and upserts it via the sidecar `.json`. + */ + async synchronizeFile(filePath: string): Promise { + if (!filePath.endsWith('.jsonl')) { + return null; + } + + const parsed = await this.parseSessionFromSidecar(filePath); + if (!parsed) { + return null; + } + + // Honor any user-set custom_name. `sessionsDb.createSession` upserts + // with `COALESCE(excluded.custom_name, sessions.custom_name)`, so any + // non-null name we pass replaces the stored one. The pattern (mirrors + // codex-session-synchronizer.provider.ts:123-131): when the existing + // session has a non-default name, re-pass that name so the COALESCE + // is a no-op; only adopt the sidecar title when no name has been set + // (or when the placeholder is still the default "Untitled" string). + const existing = sessionsDb.getSessionById(parsed.sessionId); + let nameToPersist = parsed.sessionName; + if (existing?.custom_name && existing.custom_name !== UNTITLED) { + nameToPersist = existing.custom_name; + } + + const timestamps = await readFileTimestamps(filePath); + return sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.projectPath, + nameToPersist, + timestamps.createdAt, + timestamps.updatedAt, + filePath, + ); + } + + /** + * Reads the sidecar `.json` to extract `cwd`, `session_id`, and + * `title`. Falls back to deriving the session id from the filename when the + * sidecar is missing or malformed. + */ + private async parseSessionFromSidecar(jsonlPath: string): Promise { + const sessionIdFromName = path.basename(jsonlPath, '.jsonl'); + const sidecarPath = jsonlPath.replace(/\.jsonl$/, '.json'); + + let sidecar: Record | null = null; + try { + const content = await readFile(sidecarPath, 'utf8'); + sidecar = readObjectRecord(JSON.parse(content)); + } catch { + // Missing sidecar — without a cwd we can't index the session. + return null; + } + + const sessionId = readOptionalString(sidecar?.session_id) ?? sessionIdFromName; + const projectPath = readOptionalString(sidecar?.cwd); + if (!projectPath) { + return null; + } + + const title = readOptionalString(sidecar?.title); + + return { + sessionId, + projectPath, + sessionName: normalizeSessionName(title, UNTITLED), + }; + } +} diff --git a/server/modules/providers/list/kiro/kiro-sessions.provider.ts b/server/modules/providers/list/kiro/kiro-sessions.provider.ts new file mode 100644 index 0000000000..23878629b6 --- /dev/null +++ b/server/modules/providers/list/kiro/kiro-sessions.provider.ts @@ -0,0 +1,315 @@ +import fsSync from 'node:fs'; +import readline from 'node:readline'; + +import { sessionsDb } from '@/modules/database/index.js'; +import type { IProviderSessions } from '@/shared/interfaces.js'; +import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; +import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; + +const PROVIDER = 'kiro'; + +/** + * Kiro persists ACP sessions as JSONL at `~/.kiro/sessions/cli/.jsonl`. + * Each line is `{version, kind, data}` where kind is one of: + * - "Prompt" : user input + * - "AssistantMessage": assistant text and/or tool_use content parts + * - "ToolResults" : tool_result content parts + * + * Each `data.content[]` entry is `{kind: "text"|"toolUse"|"toolResult", data}`. + */ + +type KiroContentPart = { + kind: 'text' | 'toolUse' | 'toolResult' | string; + data: unknown; +}; + +type KiroJsonlEntry = { + version?: string; + kind?: 'Prompt' | 'AssistantMessage' | 'ToolResults' | string; + data?: { + message_id?: string; + content?: KiroContentPart[]; + meta?: { timestamp?: number }; + status?: string; + }; +}; + +function isContentPart(value: unknown): value is KiroContentPart { + if (!value || typeof value !== 'object') { + return false; + } + const record = value as AnyRecord; + return typeof record.kind === 'string'; +} + +function timestampForEntry(entry: KiroJsonlEntry): string { + const epochSeconds = entry.data?.meta?.timestamp; + if (typeof epochSeconds === 'number' && Number.isFinite(epochSeconds)) { + return new Date(epochSeconds * 1000).toISOString(); + } + return new Date().toISOString(); +} + +function extractText(part: KiroContentPart): string { + if (part.kind !== 'text') { + return ''; + } + return typeof part.data === 'string' ? part.data : ''; +} + +function flattenToolResultContent(content: unknown): { text: string; isError: boolean } { + if (!Array.isArray(content)) { + return { text: typeof content === 'string' ? content : '', isError: false }; + } + + let isError = false; + const text = content + .map((part) => { + if (!isContentPart(part)) { + return ''; + } + if (part.kind === 'text') { + return typeof part.data === 'string' ? part.data : ''; + } + if (part.kind === 'error' || part.kind === 'errorText') { + isError = true; + return typeof part.data === 'string' ? part.data : JSON.stringify(part.data); + } + return ''; + }) + .filter(Boolean) + .join('\n'); + + return { text, isError }; +} + +async function readKiroJsonl(filePath: string): Promise { + const entries: KiroJsonlEntry[] = []; + const fileStream = fsSync.createReadStream(filePath); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of rl) { + if (!line.trim()) { + continue; + } + try { + entries.push(JSON.parse(line) as KiroJsonlEntry); + } catch { + // Skip malformed lines defensively — Kiro's writer is line-buffered and a + // crashed turn can leave a partial trailing line. + } + } + + return entries; +} + +export class KiroSessionsProvider implements IProviderSessions { + /** + * Normalizes a Kiro JSONL entry into the shared NormalizedMessage shape. + * + * One entry can yield multiple messages (an AssistantMessage with both + * text and toolUse parts emits text + tool_use, which is why this returns + * an array). + */ + private normalizeJsonlEntry(entry: KiroJsonlEntry, sessionId: string | null): NormalizedMessage[] { + const ts = timestampForEntry(entry); + const baseId = entry.data?.message_id ?? generateMessageId('kiro'); + const parts = Array.isArray(entry.data?.content) ? entry.data!.content! : []; + + if (entry.kind === 'Prompt') { + const text = parts.map(extractText).filter(Boolean).join('\n').trim(); + if (!text) { + return []; + } + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: text, + })]; + } + + if (entry.kind === 'AssistantMessage') { + const messages: NormalizedMessage[] = []; + + // An entry can carry multiple parts of the same kind (e.g. two text + // chunks, or text + toolUse + text). The part index disambiguates so the + // generated id is unique even when toolUseId or baseId would otherwise + // collide across parts. + for (const [partIndex, part] of parts.entries()) { + if (!isContentPart(part)) { + continue; + } + + if (part.kind === 'text') { + const text = typeof part.data === 'string' ? part.data : ''; + if (text.trim()) { + messages.push(createNormalizedMessage({ + id: `${baseId}_text_${partIndex}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: text, + })); + } + continue; + } + + if (part.kind === 'toolUse') { + const data = readObjectRecord(part.data) ?? {}; + const toolUseId = typeof data.toolUseId === 'string' ? data.toolUseId : `${baseId}_tool_${partIndex}`; + const toolName = typeof data.name === 'string' ? data.name : 'Unknown'; + messages.push(createNormalizedMessage({ + id: toolUseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName, + toolInput: data.input, + toolId: toolUseId, + })); + } + } + + return messages; + } + + if (entry.kind === 'ToolResults') { + const messages: NormalizedMessage[] = []; + // Kiro sets `data.status` to "success" or "error" at the entry level; + // the per-content-part `error`/`errorText` kinds (handled in + // `flattenToolResultContent`) are a fallback for older event versions. + const entryStatus = typeof entry.data?.status === 'string' ? entry.data!.status : null; + const entryIsError = entryStatus !== null && entryStatus !== 'success'; + + for (const [partIndex, part] of parts.entries()) { + if (!isContentPart(part) || part.kind !== 'toolResult') { + continue; + } + const data = readObjectRecord(part.data) ?? {}; + const toolUseId = typeof data.toolUseId === 'string' ? data.toolUseId : ''; + const { text, isError: contentIsError } = flattenToolResultContent(data.content); + // Two tool_result parts for the same tool would otherwise share an id. + // Use the part index as the disambiguator to keep keyed React render + // and message-association lookups stable. + messages.push(createNormalizedMessage({ + id: `${toolUseId || baseId}_result_${partIndex}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: toolUseId, + content: text, + isError: entryIsError || contentIsError, + })); + } + + return messages; + } + + return []; + } + + /** + * Normalizes either a Kiro JSONL history entry or a transformed live ACP + * `session/update` notification (forwarded by `server/kiro-cli.js`). + */ + normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { + const raw = readObjectRecord(rawMessage); + if (!raw) { + return []; + } + + if (typeof raw.kind === 'string' && raw.data && typeof raw.data === 'object') { + return this.normalizeJsonlEntry(raw as KiroJsonlEntry, sessionId); + } + + // Live ACP `session/update` notifications are pre-normalized by the runtime + // module (`server/kiro-cli.js`). Anything that reaches this method without + // the `{kind, data}` JSONL shape is treated as already-normalized. + if (typeof raw.kind === 'string' && typeof raw.provider === 'string') { + return [raw as NormalizedMessage]; + } + + return []; + } + + /** + * Loads Kiro JSONL session history off disk. + */ + async fetchHistory( + sessionId: string, + options: FetchHistoryOptions = {}, + ): Promise { + const { limit = null, offset = 0 } = options; + + let entries: KiroJsonlEntry[]; + try { + const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path; + if (!sessionFilePath) { + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + entries = await readKiroJsonl(sessionFilePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[KiroProvider] Failed to load session ${sessionId}:`, message); + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + const normalized: NormalizedMessage[] = []; + for (const entry of entries) { + normalized.push(...this.normalizeJsonlEntry(entry, sessionId)); + } + + // Backfill tool_result content onto matching tool_use messages so the UI + // can render the request/response pair as one card. Same shape as Codex's + // pairing pass (codex-sessions.provider.ts:540-553). + const toolResultMap = new Map(); + for (const msg of normalized) { + if (msg.kind === 'tool_result' && msg.toolId) { + toolResultMap.set(msg.toolId, msg); + } + } + for (const msg of normalized) { + if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { + const toolResult = toolResultMap.get(msg.toolId); + if (toolResult) { + msg.toolResult = { content: toolResult.content, isError: toolResult.isError }; + } + } + } + + const totalNormalized = normalized.length; + let total = 0; + for (const msg of normalized) { + if (msg.kind !== 'tool_result') { + total += 1; + } + } + const normalizedOffset = Math.max(0, offset); + const normalizedLimit = limit === null ? null : Math.max(0, limit); + const messages = normalizedLimit === null + ? normalized + : normalized.slice( + Math.max(0, totalNormalized - normalizedOffset - normalizedLimit), + Math.max(0, totalNormalized - normalizedOffset), + ); + const hasMore = normalizedLimit === null + ? false + : Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0; + + return { + messages, + total, + hasMore, + offset: normalizedOffset, + limit: normalizedLimit, + }; + } +} diff --git a/server/modules/providers/list/kiro/kiro-skills.provider.ts b/server/modules/providers/list/kiro/kiro-skills.provider.ts new file mode 100644 index 0000000000..68c90b9c50 --- /dev/null +++ b/server/modules/providers/list/kiro/kiro-skills.provider.ts @@ -0,0 +1,36 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js'; +import type { ProviderSkillSource } from '@/shared/types.js'; + +export class KiroSkillsProvider extends SkillsProvider { + constructor() { + super('kiro'); + } + + protected async getSkillSources(workspacePath: string): Promise { + return [ + { + scope: 'user', + rootDir: path.join(os.homedir(), '.kiro', 'skills'), + commandPrefix: '/', + }, + { + scope: 'user', + rootDir: path.join(os.homedir(), '.agents', 'skills'), + commandPrefix: '/', + }, + { + scope: 'project', + rootDir: path.join(workspacePath, '.kiro', 'skills'), + commandPrefix: '/', + }, + { + scope: 'project', + rootDir: path.join(workspacePath, '.agents', 'skills'), + commandPrefix: '/', + }, + ]; + } +} diff --git a/server/modules/providers/list/kiro/kiro.provider.ts b/server/modules/providers/list/kiro/kiro.provider.ts new file mode 100644 index 0000000000..fd3c98fe0e --- /dev/null +++ b/server/modules/providers/list/kiro/kiro.provider.ts @@ -0,0 +1,27 @@ +import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import { KiroProviderAuth } from '@/modules/providers/list/kiro/kiro-auth.provider.js'; +import { KiroProviderModels } from '@/modules/providers/list/kiro/kiro-models.provider.js'; +import { KiroMcpProvider } from '@/modules/providers/list/kiro/kiro-mcp.provider.js'; +import { KiroSessionSynchronizer } from '@/modules/providers/list/kiro/kiro-session-synchronizer.provider.js'; +import { KiroSessionsProvider } from '@/modules/providers/list/kiro/kiro-sessions.provider.js'; +import { KiroSkillsProvider } from '@/modules/providers/list/kiro/kiro-skills.provider.js'; +import type { + IProviderAuth, + IProviderModels, + IProviderSessionSynchronizer, + IProviderSessions, + IProviderSkills, +} from '@/shared/interfaces.js'; + +export class KiroProvider extends AbstractProvider { + readonly models: IProviderModels = new KiroProviderModels(); + readonly mcp = new KiroMcpProvider(); + readonly auth: IProviderAuth = new KiroProviderAuth(); + readonly skills: IProviderSkills = new KiroSkillsProvider(); + readonly sessions: IProviderSessions = new KiroSessionsProvider(); + readonly sessionSynchronizer: IProviderSessionSynchronizer = new KiroSessionSynchronizer(); + + constructor() { + super('kiro'); + } +} diff --git a/server/modules/providers/list/kiro/stdio-jsonrpc-client.ts b/server/modules/providers/list/kiro/stdio-jsonrpc-client.ts new file mode 100644 index 0000000000..3c88dd4759 --- /dev/null +++ b/server/modules/providers/list/kiro/stdio-jsonrpc-client.ts @@ -0,0 +1,240 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; + +/** + * Minimal JSON-RPC 2.0 client over a child process's stdio. + * + * Designed for Kiro CLI's ACP (Agent Client Protocol) endpoint, but agnostic + * of the method names used. Handles: + * - line-buffered stdout (one JSON-RPC frame per line) + * - request/response correlation by `id` + * - notification dispatch by method name (with prefix-matching support so + * Kiro's `_kiro.dev/*` extension namespace can register a wildcard handler) + * - graceful close that rejects all in-flight requests with the close reason + */ + +type Handler = (params: unknown) => void; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; + method: string; +}; + +export type StdioJsonRpcClientOptions = { + /** Maximum time to wait for a response, in ms. Default 120_000 (2 minutes). */ + requestTimeoutMs?: number; + /** Optional callback for stderr lines from the child. */ + onStderr?: (line: string) => void; + /** Optional callback for parse failures. */ + onParseError?: (rawLine: string, error: unknown) => void; +}; + +/** + * Wraps a spawned child process and exposes JSON-RPC request/notify/onNotification. + * + * Caller owns the child process lifecycle (spawning and killing). This client + * only attaches stdout/stderr listeners and writes to stdin. + */ +export class StdioJsonRpcClient { + private nextId = 1; + private readonly pending = new Map(); + private readonly handlers = new Map(); + private readonly prefixHandlers = new Map(); + private readonly options: Required> & + Pick; + private stdoutBuffer = ''; + private closed = false; + + constructor( + private readonly child: ChildProcessWithoutNullStreams, + options: StdioJsonRpcClientOptions = {}, + ) { + this.options = { + requestTimeoutMs: options.requestTimeoutMs ?? 120_000, + onStderr: options.onStderr, + onParseError: options.onParseError, + }; + + this.child.stdout.setEncoding('utf8'); + this.child.stdout.on('data', this.handleStdoutChunk); + if (this.child.stderr) { + this.child.stderr.setEncoding('utf8'); + this.child.stderr.on('data', this.handleStderrChunk); + } + this.child.on('close', this.handleClose); + this.child.on('error', (error) => this.handleClose(null, null, error)); + } + + /** + * Sends a JSON-RPC request and resolves with the typed result. + */ + request(method: string, params?: unknown): Promise { + if (this.closed) { + return Promise.reject(new Error(`JSON-RPC client is closed (request: ${method})`)); + } + + const id = this.nextId; + this.nextId += 1; + const frame = JSON.stringify({ jsonrpc: '2.0', id, method, params }); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`JSON-RPC request '${method}' timed out after ${this.options.requestTimeoutMs}ms`)); + }, this.options.requestTimeoutMs); + + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value as TResult); + }, + reject: (reason) => { + clearTimeout(timer); + reject(reason); + }, + method, + }); + + this.child.stdin.write(`${frame}\n`, (error) => { + if (error) { + clearTimeout(timer); + this.pending.delete(id); + reject(error); + } + }); + }); + } + + /** + * Sends a JSON-RPC notification (no response expected). + */ + notify(method: string, params?: unknown): void { + if (this.closed) { + return; + } + const frame = JSON.stringify({ jsonrpc: '2.0', method, params }); + this.child.stdin.write(`${frame}\n`); + } + + /** + * Registers a notification handler for a specific method name. + * + * Returns a disposer that removes the handler. + */ + onNotification(method: string, handler: Handler): () => void { + this.handlers.set(method, handler); + return () => this.handlers.delete(method); + } + + /** + * Registers a notification handler for any method whose name starts with the + * given prefix (e.g. `_kiro.dev/`). Useful for protocol extension namespaces. + */ + onNotificationPrefix(prefix: string, handler: Handler): () => void { + this.prefixHandlers.set(prefix, handler); + return () => this.prefixHandlers.delete(prefix); + } + + /** + * True after the child process has exited or errored. + */ + isClosed(): boolean { + return this.closed; + } + + private handleStdoutChunk = (chunk: string): void => { + this.stdoutBuffer += chunk; + + let newlineIndex: number; + while ((newlineIndex = this.stdoutBuffer.indexOf('\n')) >= 0) { + const rawLine = this.stdoutBuffer.slice(0, newlineIndex).trim(); + this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1); + if (!rawLine) { + continue; + } + + let frame: Record; + try { + const parsed = JSON.parse(rawLine); + if (!parsed || typeof parsed !== 'object') { + continue; + } + frame = parsed as Record; + } catch (error) { + this.options.onParseError?.(rawLine, error); + continue; + } + + this.dispatchFrame(frame); + } + }; + + private handleStderrChunk = (chunk: string): void => { + if (!this.options.onStderr) { + return; + } + for (const line of chunk.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed) { + this.options.onStderr(trimmed); + } + } + }; + + private handleClose = (code?: number | null, _signal?: NodeJS.Signals | null, error?: Error): void => { + if (this.closed) { + return; + } + this.closed = true; + const reason = error + ? error + : new Error(code === 0 ? 'JSON-RPC stream closed' : `JSON-RPC stream closed with code ${code ?? 'unknown'}`); + for (const pending of this.pending.values()) { + pending.reject(reason); + } + this.pending.clear(); + }; + + private dispatchFrame(frame: Record): void { + const id = typeof frame.id === 'number' ? frame.id : null; + const method = typeof frame.method === 'string' ? frame.method : null; + + if (id !== null && this.pending.has(id)) { + const pending = this.pending.get(id)!; + this.pending.delete(id); + if (frame.error && typeof frame.error === 'object') { + const err = frame.error as Record; + const message = typeof err.message === 'string' ? err.message : 'JSON-RPC error'; + const rpcError = new Error(`${message} (method: ${pending.method})`); + (rpcError as Error & { data?: unknown }).data = err.data; + pending.reject(rpcError); + return; + } + pending.resolve(frame.result); + return; + } + + if (method) { + const params = frame.params; + const exact = this.handlers.get(method); + if (exact) { + try { + exact(params); + } catch (handlerError) { + // Handler errors must not break the JSON-RPC stream — but they ARE + // logged so silent regressions don't slip past the stderr console. + console.error(`[StdioJsonRpcClient] notification handler for "${method}" threw:`, handlerError); + } + } + for (const [prefix, handler] of this.prefixHandlers) { + if (method.startsWith(prefix)) { + try { + handler(params); + } catch (handlerError) { + console.error(`[StdioJsonRpcClient] prefix-"${prefix}" handler threw on "${method}":`, handlerError); + } + } + } + } + } +} diff --git a/server/modules/providers/provider.registry.ts b/server/modules/providers/provider.registry.ts index a9f0d26b1a..109eecf564 100644 --- a/server/modules/providers/provider.registry.ts +++ b/server/modules/providers/provider.registry.ts @@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider. import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js'; import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js'; import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js'; +import { KiroProvider } from '@/modules/providers/list/kiro/kiro.provider.js'; import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js'; import type { IProvider } from '@/shared/interfaces.js'; import type { LLMProvider } from '@/shared/types.js'; @@ -13,6 +14,7 @@ const providers: Record = { cursor: new CursorProvider(), gemini: new GeminiProvider(), opencode: new OpenCodeProvider(), + kiro: new KiroProvider(), }; /** diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index ec76a7db11..8d8ef9274f 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -187,6 +187,7 @@ const parseProvider = (value: unknown): LLMProvider => { || normalized === 'cursor' || normalized === 'gemini' || normalized === 'opencode' + || normalized === 'kiro' ) { return normalized; } diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index 1b7cbbb3f1..8a164a7116 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -75,6 +75,18 @@ const PROVIDER_CAPABILITIES: Record = { supportsPermissionRequests: false, supportsTokenUsage: true, }, + kiro: { + provider: 'kiro', + // Kiro runs ACP with `--trust-all-tools`, so it has no interactive + // permission ladder and no tool-approval prompts. Token/credit usage is + // not yet surfaced by the runtime (see kiro-cli.js), so it stays off. + permissionModes: ['default'], + defaultPermissionMode: 'default', + supportsImages: false, + supportsAbort: true, + supportsPermissionRequests: false, + supportsTokenUsage: false, + }, }; /** diff --git a/server/modules/providers/services/session-synchronizer.service.ts b/server/modules/providers/services/session-synchronizer.service.ts index 55b41f9e28..9c588734f0 100644 --- a/server/modules/providers/services/session-synchronizer.service.ts +++ b/server/modules/providers/services/session-synchronizer.service.ts @@ -23,6 +23,7 @@ export const sessionSynchronizerService = { cursor: 0, gemini: 0, opencode: 0, + kiro: 0, }; const failures: string[] = []; diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index cfbdb88756..06ab0aadd9 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -39,6 +39,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = provider: 'opencode', rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'), }, + { + provider: 'kiro', + rootPath: path.join(os.homedir(), '.kiro', 'sessions', 'cli'), + }, ]; const WATCHER_IGNORED_PATTERNS = [ diff --git a/server/modules/providers/tests/mcp.test.ts b/server/modules/providers/tests/mcp.test.ts index f10b135489..693a0f82ee 100644 --- a/server/modules/providers/tests/mcp.test.ts +++ b/server/modules/providers/tests/mcp.test.ts @@ -341,7 +341,8 @@ test('providerMcpService global adder writes to all providers and rejects unsupp workspacePath, }); - assert.equal(globalResult.length, 5); + // Six providers (claude, codex, cursor, gemini, opencode, kiro). + assert.equal(globalResult.length, 6); assert.ok(globalResult.every((entry) => entry.created === true)); const claudeProject = await readJson(path.join(workspacePath, '.mcp.json')); @@ -359,6 +360,9 @@ test('providerMcpService global adder writes to all providers and rejects unsupp const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json')); assert.ok((cursorProject.mcpServers as Record)['global-http']); + const kiroProject = await readJson(path.join(workspacePath, '.kiro', 'settings', 'mcp.json')); + assert.ok((kiroProject.mcpServers as Record)['global-http']); + await assert.rejects( providerMcpService.addMcpServerToAllProviders({ name: 'global-sse', diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index d41b781f7a..bb75fee725 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -161,6 +161,16 @@ function buildShellCommand( return initialCommand || 'opencode'; } + if (provider === 'kiro') { + // The Shell tab drives the interactive `kiro-cli chat` REPL (the binary is + // `kiro-cli`, not `kiro`); the ACP/`--trust-all-tools` invocation is for + // the headless chat gateway only. Resume targets a specific conversation. + if (resumeSessionId) { + return `kiro-cli chat --resume-id "${resumeSessionId}"`; + } + return initialCommand || 'kiro-cli chat'; + } + const command = initialCommand || 'claude'; if (resumeSessionId) { if (os.platform() === 'win32') { @@ -423,6 +433,8 @@ export function handleShellConnection( ? 'Gemini' : provider === 'opencode' ? 'OpenCode' + : provider === 'kiro' + ? 'Kiro' : 'Claude'; welcomeMsg = hasSession && resumeSessionId ? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n` diff --git a/server/routes/agent.js b/server/routes/agent.js index f2273181fe..f6593a7b24 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -10,6 +10,7 @@ import { spawnCursor } from '../cursor-cli.js'; import { queryCodex } from '../openai-codex.js'; import { spawnGemini } from '../gemini-cli.js'; import { spawnOpenCode } from '../opencode-cli.js'; +import { spawnKiro } from '../kiro-cli.js'; import { Octokit } from '@octokit/rest'; import { providerModelsService } from '../modules/providers/services/provider-models.service.js'; import { IS_PLATFORM } from '../constants/config.js'; @@ -636,7 +637,7 @@ class ResponseCollector { * - Source for auto-generated branch names (if createBranch=true and no branchName) * - Fallback for PR title if no commits are made * - * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' + * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'kiro' * Default: 'claude' * * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates. @@ -754,7 +755,7 @@ class ResponseCollector { * Input Validations (400 Bad Request): * - Either githubUrl OR projectPath must be provided (not neither) * - message must be non-empty string - * - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode' + * - provider must be 'claude', 'cursor', 'codex', 'gemini', 'opencode', or 'kiro' * - createBranch/createPR requires githubUrl OR projectPath (not neither) * - branchName must pass Git naming rules (if provided) * @@ -862,8 +863,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { return res.status(400).json({ error: 'message is required' }); } - if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) { - return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' }); + if (!['claude', 'cursor', 'codex', 'gemini', 'opencode', 'kiro'].includes(provider)) { + return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", "opencode", or "kiro"' }); } // Validate GitHub branch/PR creation requirements @@ -996,6 +997,15 @@ router.post('/', validateExternalApiKey, async (req, res) => { sessionId: sessionId || null, model: model || opencodeModels.DEFAULT }, writer); + } else if (provider === 'kiro') { + console.log('☁️ Starting Kiro CLI session'); + + await spawnKiro(message.trim(), { + projectPath: finalProjectPath, + cwd: finalProjectPath, + sessionId: sessionId || null, + model: model || undefined, + }, writer); } // Handle GitHub branch and PR creation after successful agent completion diff --git a/server/routes/commands.js b/server/routes/commands.js index ea223f1238..dbc548c3ff 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -15,7 +15,7 @@ const APP_ROOT = findAppRoot(__dirname); const router = express.Router(); -const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"]; +const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode", "kiro"]; const MODEL_PROVIDER_LABELS = { claude: "Claude", @@ -23,6 +23,7 @@ const MODEL_PROVIDER_LABELS = { codex: "Codex", gemini: "Gemini", opencode: "OpenCode", + kiro: "Kiro", }; const readModelProvider = (value) => { @@ -531,16 +532,31 @@ router.post("/execute", async (req, res) => { } // Load command content - // Security: validate commandPath is within allowed directories + // Security: validate commandPath is within allowed directories. + // Resolve to canonical paths (realpath) first so a symlink inside + // .claude/commands cannot point at a file outside the allowed roots and + // still pass the containment check. realpath throws if the target does not + // exist, which we treat as access denied. { - const resolvedPath = path.resolve(commandPath); - const userBase = path.resolve( + const canonicalize = async (target) => { + try { + return await fs.realpath(target); + } catch { + return null; + } + }; + + const resolvedPath = await canonicalize(commandPath); + const userBase = await canonicalize( path.join(os.homedir(), ".claude", "commands"), ); const projectBase = context?.projectPath - ? path.resolve(path.join(context.projectPath, ".claude", "commands")) + ? await canonicalize(path.join(context.projectPath, ".claude", "commands")) : null; const isUnder = (base) => { + if (!base || !resolvedPath) { + return false; + } const rel = path.relative(base, resolvedPath); return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); }; diff --git a/server/shared/types.ts b/server/shared/types.ts index 91c477a609..ad503b0a13 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -65,7 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & { * Use this as the source of truth whenever a function or payload needs to identify * a specific LLM integration. */ -export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode'; +export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'kiro'; /** * One selectable model row in a provider model catalog. diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index c1f86f2db9..5991723dec 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -39,6 +39,7 @@ interface UseChatComposerStateArgs { codexModel: string; geminiModel: string; opencodeModel: string; + kiroModel: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | null; @@ -173,6 +174,7 @@ export function useChatComposerState({ codexModel, geminiModel, opencodeModel, + kiroModel, isLoading, canAbortSession, tokenBudget, @@ -334,7 +336,9 @@ export function useChatComposerState({ ? geminiModel : provider === 'opencode' ? opencodeModel - : claudeModel, + : provider === 'kiro' + ? kiroModel + : claudeModel, tokenUsage: tokenBudget, }; @@ -389,6 +393,7 @@ export function useChatComposerState({ cursorModel, geminiModel, opencodeModel, + kiroModel, handleBuiltInCommand, handleCustomCommand, input, @@ -685,6 +690,8 @@ export function useChatComposerState({ ? 'gemini-settings' : provider === 'opencode' ? 'opencode-settings' + : provider === 'kiro' + ? 'kiro-settings' : 'claude-settings'; const savedSettings = safeLocalStorage.getItem(settingsKey); if (savedSettings) { @@ -711,7 +718,9 @@ export function useChatComposerState({ ? geminiModel : provider === 'opencode' ? opencodeModel - : claudeModel; + : provider === 'kiro' + ? kiroModel + : claudeModel; // One message shape for every provider. The backend resolves the // provider, project path, and provider-native resume id from the @@ -756,6 +765,7 @@ export function useChatComposerState({ executeCommand, geminiModel, opencodeModel, + kiroModel, isLoading, onSessionProcessing, onSessionEstablished, diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index a2910b0df4..a4685fd8a0 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -15,6 +15,7 @@ const FALLBACK_DEFAULT_MODEL: Record = { codex: 'gpt-5.4', gemini: 'gemini-3.1-pro-preview', opencode: 'anthropic/claude-sonnet-4-5', + kiro: 'auto', }; /** @@ -29,6 +30,7 @@ const FALLBACK_PERMISSION_MODES: Record = { codex: ['default', 'acceptEdits', 'bypassPermissions'], gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], opencode: ['default'], + kiro: ['default'], }; type ProviderCapabilities = { @@ -93,6 +95,9 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const [opencodeModel, setOpenCodeModel] = useState(() => { return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode; }); + const [kiroModel, setKiroModel] = useState(() => { + return localStorage.getItem('kiro-model') || FALLBACK_DEFAULT_MODEL.kiro; + }); /** * Backend-owned capability matrix keyed by provider. Drives the permission @@ -142,12 +147,18 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh return; } + if (targetProvider === 'kiro') { + setKiroModel(model); + localStorage.setItem('kiro-model', model); + return; + } + setOpenCodeModel(model); localStorage.setItem('opencode-model', model); }, []); const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => { - const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; + const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'kiro']; const requestId = providerModelsRequestIdRef.current + 1; providerModelsRequestIdRef.current = requestId; const isHardRefresh = options.bypassCache === true; @@ -325,6 +336,19 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh } }, [providerModelCatalog.opencode, opencodeModel]); + useEffect(() => { + const kiro = providerModelCatalog.kiro; + if (kiro) { + const next = pickStoredOrCurrent('kiro-model', kiroModel, kiro); + if (next !== kiroModel) { + setKiroModel(next); + } + if (localStorage.getItem('kiro-model') !== next) { + localStorage.setItem('kiro-model', next); + } + } + }, [providerModelCatalog.kiro, kiroModel]); + useEffect(() => { if (!selectedSession?.id) { return; @@ -441,6 +465,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh setGeminiModel, opencodeModel, setOpenCodeModel, + kiroModel, + setKiroModel, permissionMode, setPermissionMode, pendingPermissionRequests, diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 5efe6af487..f2f92eda51 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -75,6 +75,8 @@ function ChatInterface({ setGeminiModel, opencodeModel, setOpenCodeModel, + kiroModel, + setKiroModel, permissionMode, pendingPermissionRequests, setPendingPermissionRequests, @@ -200,6 +202,7 @@ function ChatInterface({ codexModel, geminiModel, opencodeModel, + kiroModel, isLoading: isProcessing, canAbortSession, tokenBudget, @@ -291,6 +294,8 @@ function ChatInterface({ ? t('messageTypes.gemini') : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) + : provider === 'kiro' + ? t('messageTypes.kiro', { defaultValue: 'Kiro' }) : t('messageTypes.claude'); return ( @@ -334,6 +339,8 @@ function ChatInterface({ setOpenCodeModel={setOpenCodeModel} providerModelCatalog={providerModelCatalog} providerModelsLoading={providerModelsLoading} + kiroModel={kiroModel} + setKiroModel={setKiroModel} tasksEnabled={tasksEnabled} isTaskMasterInstalled={isTaskMasterInstalled} onShowAllTasks={onShowAllTasks} @@ -422,6 +429,8 @@ function ChatInterface({ ? t('messageTypes.gemini') : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) + : provider === 'kiro' + ? t('messageTypes.kiro', { defaultValue: 'Kiro' }) : t('messageTypes.claude'), })} isTextareaExpanded={isTextareaExpanded} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 5573b31fa1..801a0065c9 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -39,6 +39,8 @@ interface ChatMessagesPaneProps { setOpenCodeModel: (model: string) => void; providerModelCatalog: Partial>; providerModelsLoading: boolean; + kiroModel: string; + setKiroModel: (model: string) => void; tasksEnabled: boolean; isTaskMasterInstalled: boolean | null; onShowAllTasks?: (() => void) | null; @@ -89,6 +91,8 @@ export default function ChatMessagesPane({ setOpenCodeModel, providerModelCatalog, providerModelsLoading, + kiroModel, + setKiroModel, tasksEnabled, isTaskMasterInstalled, onShowAllTasks, @@ -176,6 +180,8 @@ export default function ChatMessagesPane({ setOpenCodeModel={setOpenCodeModel} providerModelCatalog={providerModelCatalog} providerModelsLoading={providerModelsLoading} + kiroModel={kiroModel} + setKiroModel={setKiroModel} tasksEnabled={tasksEnabled} isTaskMasterInstalled={isTaskMasterInstalled} onShowAllTasks={onShowAllTasks} diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx index 2e391ebe8c..20580a9f0b 100644 --- a/src/components/chat/view/subcomponents/CommandResultModal.tsx +++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx @@ -78,6 +78,7 @@ const PROVIDER_LABELS: Record = { codex: 'Codex', gemini: 'Gemini', opencode: 'OpenCode', + kiro: 'Kiro', }; const FALLBACK_COMMANDS: CommandEntry[] = [ diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index ba69bf90e9..d02691b783 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -182,7 +182,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a ? t('messageTypes.gemini') : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) - : t('messageTypes.claude'))} + : provider === 'kiro' + ? t('messageTypes.kiro', { defaultValue: 'Kiro' }) + : t('messageTypes.claude'))} )} diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 6d97ca886d..44141e519a 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -29,6 +29,7 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [ { id: "gemini", name: "Google" }, { id: "cursor", name: "Cursor" }, { id: "opencode", name: "OpenCode" }, + { id: "kiro", name: "AWS Kiro" }, ]; const MOD_KEY = @@ -50,6 +51,8 @@ type ProviderSelectionEmptyStateProps = { setGeminiModel: (model: string) => void; opencodeModel: string; setOpenCodeModel: (model: string) => void; + kiroModel: string; + setKiroModel: (model: string) => void; providerModelCatalog: Partial>; providerModelsLoading: boolean; tasksEnabled: boolean; @@ -79,11 +82,13 @@ function getCurrentModel( co: string, g: string, o: string, + k: string, ) { if (p === "claude") return c; if (p === "codex") return co; if (p === "gemini") return g; if (p === "opencode") return o; + if (p === "kiro") return k; return cu; } @@ -92,6 +97,7 @@ function getProviderDisplayName(p: LLMProvider) { if (p === "cursor") return "Cursor"; if (p === "codex") return "Codex"; if (p === "opencode") return "OpenCode"; + if (p === "kiro") return "Kiro"; return "Gemini"; } @@ -111,6 +117,8 @@ export default function ProviderSelectionEmptyState({ setGeminiModel, opencodeModel, setOpenCodeModel, + kiroModel, + setKiroModel, providerModelCatalog, providerModelsLoading, tasksEnabled, @@ -140,6 +148,7 @@ export default function ProviderSelectionEmptyState({ codexModel, geminiModel, opencodeModel, + kiroModel, ); const currentModelLabel = useMemo(() => { @@ -164,12 +173,15 @@ export default function ProviderSelectionEmptyState({ } else if (providerId === "opencode") { setOpenCodeModel(modelValue); localStorage.setItem("opencode-model", modelValue); + } else if (providerId === "kiro") { + setKiroModel(modelValue); + localStorage.setItem("kiro-model", modelValue); } else { setCursorModel(modelValue); localStorage.setItem("cursor-model", modelValue); } }, - [setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel], + [setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel, setKiroModel], ); const handleModelSelect = useCallback( @@ -230,9 +242,13 @@ export default function ProviderSelectionEmptyState({ - Model Selector + + {t("providerSelection.modelSelectorTitle", { defaultValue: "Model Selector" })} +
-

Choose a model

+

+ {t("providerSelection.chooseModel", { defaultValue: "Choose a model" })} +

diff --git a/src/components/llm-logo-provider/KiroLogo.tsx b/src/components/llm-logo-provider/KiroLogo.tsx new file mode 100644 index 0000000000..117bbe2227 --- /dev/null +++ b/src/components/llm-logo-provider/KiroLogo.tsx @@ -0,0 +1,19 @@ +import { useTheme } from '../../contexts/ThemeContext'; + +type KiroLogoProps = { + className?: string; +}; + +const KiroLogo = ({ className = 'w-5 h-5' }: KiroLogoProps) => { + const { isDarkMode } = useTheme(); + + return ( + Kiro + ); +}; + +export default KiroLogo; diff --git a/src/components/llm-logo-provider/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx index e29ecd6d14..2d7d4820f3 100644 --- a/src/components/llm-logo-provider/SessionProviderLogo.tsx +++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx @@ -4,6 +4,7 @@ import CodexLogo from './CodexLogo'; import CursorLogo from './CursorLogo'; import GeminiLogo from './GeminiLogo'; import OpenCodeLogo from './OpenCodeLogo'; +import KiroLogo from './KiroLogo'; type SessionProviderLogoProps = { provider?: LLMProvider | string | null; @@ -30,5 +31,9 @@ export default function SessionProviderLogo({ return ; } + if (provider === 'kiro') { + return ; + } + return ; } diff --git a/src/components/mcp/constants.ts b/src/components/mcp/constants.ts index ab6396ff72..08997c770b 100644 --- a/src/components/mcp/constants.ts +++ b/src/components/mcp/constants.ts @@ -6,6 +6,7 @@ export const MCP_PROVIDER_NAMES: Record = { codex: 'Codex', gemini: 'Gemini', opencode: 'OpenCode', + kiro: 'Kiro', }; export const MCP_SUPPORTED_SCOPES: Record = { @@ -14,6 +15,7 @@ export const MCP_SUPPORTED_SCOPES: Record = { codex: ['user', 'project'], gemini: ['user', 'project'], opencode: ['user', 'project'], + kiro: ['user', 'project'], }; export const MCP_SUPPORTED_TRANSPORTS: Record = { @@ -22,6 +24,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record = { codex: ['stdio', 'http'], gemini: ['stdio', 'http', 'sse'], opencode: ['stdio', 'http'], + kiro: ['stdio', 'http'], }; export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project']; @@ -34,6 +37,7 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record = { codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600', gemini: 'bg-blue-600 text-white hover:bg-blue-700', opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600', + kiro: 'bg-slate-800 text-white hover:bg-slate-900 dark:bg-slate-700 dark:hover:bg-slate-600', }; export const MCP_SUPPORTS_WORKING_DIRECTORY: Record = { @@ -42,6 +46,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record = { codex: true, gemini: true, opencode: false, + kiro: false, }; export const DEFAULT_MCP_FORM: McpFormState = { diff --git a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx index 5c66997a1b..da7c75b640 100644 --- a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx +++ b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx @@ -44,6 +44,13 @@ const providerCards = [ iconContainerClassName: 'bg-zinc-100 dark:bg-zinc-800', loginButtonClassName: 'bg-zinc-800 hover:bg-zinc-900 dark:bg-zinc-700 dark:hover:bg-zinc-600', }, + { + provider: 'kiro' as const, + title: 'AWS Kiro', + connectedClassName: 'bg-slate-50 dark:bg-slate-900/20 border-slate-200 dark:border-slate-800', + iconContainerClassName: 'bg-slate-100 dark:bg-slate-900/30', + loginButtonClassName: 'bg-slate-700 hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600', + }, ]; export default function AgentConnectionsStep({ diff --git a/src/components/provider-auth/types.ts b/src/components/provider-auth/types.ts index afa0809411..890096c627 100644 --- a/src/components/provider-auth/types.ts +++ b/src/components/provider-auth/types.ts @@ -10,7 +10,7 @@ export type ProviderAuthStatus = { export type ProviderAuthStatusMap = Record; -export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; +export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'kiro']; export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = { claude: '/api/providers/claude/auth/status', @@ -18,6 +18,7 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = { codex: '/api/providers/codex/auth/status', gemini: '/api/providers/gemini/auth/status', opencode: '/api/providers/opencode/auth/status', + kiro: '/api/providers/kiro/auth/status', }; export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({ @@ -26,4 +27,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth codex: { authenticated: false, email: null, method: null, error: null, loading }, gemini: { authenticated: false, email: null, method: null, error: null, loading }, opencode: { authenticated: false, email: null, method: null, error: null, loading }, + kiro: { authenticated: false, email: null, method: null, error: null, loading }, }); diff --git a/src/components/provider-auth/view/ProviderLoginModal.tsx b/src/components/provider-auth/view/ProviderLoginModal.tsx index 9de1d227b0..07e355a75e 100644 --- a/src/components/provider-auth/view/ProviderLoginModal.tsx +++ b/src/components/provider-auth/view/ProviderLoginModal.tsx @@ -41,6 +41,10 @@ const getProviderCommand = ({ return 'opencode auth login'; } + if (provider === 'kiro') { + return 'kiro-cli login'; + } + return 'gemini status'; }; @@ -49,6 +53,7 @@ const getProviderTitle = (provider: LLMProvider) => { if (provider === 'cursor') return 'Cursor CLI Login'; if (provider === 'codex') return 'Codex CLI Login'; if (provider === 'opencode') return 'OpenCode CLI Login'; + if (provider === 'kiro') return 'Kiro CLI Login'; return 'Gemini CLI Configuration'; }; diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index 7d4c353df1..3a0d0ea295 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -37,7 +37,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [ { id: 'about', label: 'About', keywords: 'about version info', icon: Info }, ]; -export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; +export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'kiro']; export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp']; export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name'; diff --git a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx index b23784ee67..55b77fc40c 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx @@ -12,7 +12,7 @@ type AgentListItemProps = { type AgentConfig = { name: string; - color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc'; + color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc' | 'slate'; }; const agentConfig: Record = { @@ -36,6 +36,10 @@ const agentConfig: Record = { name: 'OpenCode', color: 'zinc', }, + kiro: { + name: 'Kiro', + color: 'slate', + }, }; const colorClasses = { @@ -54,6 +58,9 @@ const colorClasses = { zinc: { dot: 'bg-zinc-500', }, + slate: { + dot: 'bg-slate-500', + }, } as const; export default function AgentListItem({ diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx index ee24239cff..bcb6703d39 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx @@ -24,7 +24,7 @@ export default function AgentsSettingsTab({ const [selectedCategory, setSelectedCategory] = useState('account'); const visibleAgents = useMemo(() => { - return ['claude', 'cursor', 'codex', 'gemini', 'opencode']; + return ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'kiro']; }, []); const agentContextById = useMemo>(() => ({ @@ -48,6 +48,10 @@ export default function AgentsSettingsTab({ authStatus: providerAuthStatus.opencode, onLogin: () => onProviderLogin('opencode'), }, + kiro: { + authStatus: providerAuthStatus.kiro, + onLogin: () => onProviderLogin('kiro'), + }, }), [ onProviderLogin, providerAuthStatus.claude, @@ -55,6 +59,7 @@ export default function AgentsSettingsTab({ providerAuthStatus.cursor, providerAuthStatus.gemini, providerAuthStatus.opencode, + providerAuthStatus.kiro, ]); return ( diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx index a6d017fa3c..502151bd0a 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx @@ -9,6 +9,7 @@ const AGENT_NAMES: Record = { codex: 'Codex', gemini: 'Gemini', opencode: 'OpenCode', + kiro: 'Kiro', }; export default function AgentSelectorSection({ @@ -25,7 +26,8 @@ export default function AgentSelectorSection({ agent === 'claude' ? 'bg-blue-500' : agent === 'cursor' ? 'bg-purple-500' : agent === 'gemini' ? 'bg-indigo-500' : - agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60'; + agent === 'opencode' ? 'bg-zinc-500' : + agent === 'kiro' ? 'bg-slate-500' : 'bg-foreground/60'; return ( = { subtextClass: 'text-zinc-700 dark:text-zinc-300', buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600', }, + kiro: { + name: 'Kiro', + description: 'AWS Kiro agentic IDE', + bgClass: 'bg-slate-50 dark:bg-slate-900/20', + borderClass: 'border-slate-200 dark:border-slate-800', + textClass: 'text-slate-900 dark:text-slate-100', + subtextClass: 'text-slate-700 dark:text-slate-300', + buttonClass: 'bg-slate-700 hover:bg-slate-800 active:bg-slate-900', + }, }; export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) { diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index f0b10d7a85..58d16b0a32 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -18,7 +18,8 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "kiro": "Kiro" }, "tools": { "settings": "Werkzeugeinstellungen", @@ -153,7 +154,8 @@ "cursor": "Bereit, Cursor mit {{model}} zu verwenden. Gib unten deine Nachricht ein.", "codex": "Bereit, Codex mit {{model}} zu verwenden. Gib unten deine Nachricht ein.", "gemini": "Bereit, Gemini mit {{model}} zu verwenden. Gib unten deine Nachricht ein.", - "default": "Wähl oben einen Anbieter, um zu beginnen" + "default": "Wähl oben einen Anbieter, um zu beginnen", + "kiro": "Bereit zur Nutzung von Kiro mit {{model}}. Beginnen Sie, Ihre Nachricht unten einzugeben." }, "pressToSearch": "Drücke {{shortcut}}, um Sitzungen, Dateien und Commits zu durchsuchen" }, diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 2c75fad080..81d9181dcc 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -19,7 +19,8 @@ "cursor": "Cursor", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "kiro": "Kiro" }, "tools": { "settings": "Tool Settings", @@ -155,6 +156,7 @@ "codex": "Ready to use Codex with {{model}}. Start typing your message below.", "gemini": "Ready to use Gemini with {{model}}. Start typing your message below.", "opencode": "Ready to use OpenCode with {{model}}. Start typing your message below.", + "kiro": "Ready to use Kiro with {{model}}. Start typing your message below.", "default": "Select a provider above to begin" }, "pressToSearch": "Press {{shortcut}} to search sessions, files, and commits" diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index ae845d9a18..2cbe370e55 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -18,7 +18,8 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "kiro": "Kiro" }, "tools": { "settings": "Impostazioni strumento", @@ -153,7 +154,8 @@ "cursor": "Pronto a usare Cursor con {{model}}. Inizia a digitare il tuo messaggio qui sotto.", "codex": "Pronto a usare Codex con {{model}}. Inizia a digitare il tuo messaggio qui sotto.", "gemini": "Pronto a usare Gemini con {{model}}. Inizia a digitare il tuo messaggio qui sotto.", - "default": "Seleziona un provider sopra per iniziare" + "default": "Seleziona un provider sopra per iniziare", + "kiro": "Pronto per usare Kiro con {{model}}. Inizia a digitare il tuo messaggio qui sotto." }, "pressToSearch": "Premi {{shortcut}} per cercare sessioni, file e commit" }, diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index cd20292e8f..13cadc406f 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -17,7 +17,8 @@ "tool": "ツール", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex" + "codex": "Codex", + "kiro": "Kiro" }, "tools": { "settings": "ツール設定", @@ -130,7 +131,8 @@ "claude": "{{model}}でClaudeを使用する準備ができました。下にメッセージを入力してください。", "cursor": "{{model}}でCursorを使用する準備ができました。下にメッセージを入力してください。", "codex": "{{model}}でCodexを使用する準備ができました。下にメッセージを入力してください。", - "default": "上からプロバイダーを選択して開始してください" + "default": "上からプロバイダーを選択して開始してください", + "kiro": "Kiro を {{model}} で使用する準備ができました。下にメッセージを入力してください。" }, "pressToSearch": "{{shortcut}} を押してセッション、ファイル、コミットを検索" }, diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index c9df5c2ec9..4cc19f61ba 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -18,7 +18,8 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "kiro": "Kiro" }, "tools": { "settings": "도구 설정", @@ -135,7 +136,8 @@ "cursor": "{{model}} 모델로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.", "codex": "{{model}} 모델로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.", "gemini": "{{model}} 모델로 Gemini를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.", - "default": "시작하려면 위에서 제공자를 선택하세요" + "default": "시작하려면 위에서 제공자를 선택하세요", + "kiro": "Kiro를 {{model}}와 함께 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요." }, "pressToSearch": "{{shortcut}}를 눌러 세션, 파일 및 커밋을 검색하세요" }, diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 8d3e9e093c..7aa856783e 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -18,7 +18,8 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "kiro": "Kiro" }, "tools": { "settings": "Настройки инструмента", @@ -153,7 +154,8 @@ "cursor": "Готов использовать Cursor с {{model}}. Начните вводить сообщение ниже.", "codex": "Готов использовать Codex с {{model}}. Начните вводить сообщение ниже.", "gemini": "Готов использовать Gemini с {{model}}. Начните вводить сообщение ниже.", - "default": "Выберите провайдера выше для начала" + "default": "Выберите провайдера выше для начала", + "kiro": "Kiro готов к работе с {{model}}. Начните вводить сообщение ниже." }, "pressToSearch": "Нажмите {{shortcut}}, чтобы искать сессии, файлы и коммиты" }, diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json index 74ce954807..c092290516 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -18,7 +18,8 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "kiro": "Kiro" }, "tools": { "settings": "Araç Ayarları", @@ -153,7 +154,8 @@ "cursor": "Cursor'ı {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.", "codex": "Codex'i {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.", "gemini": "Gemini'yi {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.", - "default": "Başlamak için yukarıdan bir sağlayıcı seç" + "default": "Başlamak için yukarıdan bir sağlayıcı seç", + "kiro": "Kiro {{model}} ile kullanıma hazır. Mesajınızı aşağıya yazmaya başlayın." }, "pressToSearch": "Oturumlarda, dosyalarda ve commit'lerde arama yapmak için {{shortcut}} tuşlarına bas" }, diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 8ebe68d14a..5cb7bd4e66 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -18,7 +18,8 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "kiro": "Kiro" }, "tools": { "settings": "工具设置", @@ -135,7 +136,8 @@ "cursor": "准备好使用带有 {{model}} 的 Cursor。请在下方开始输入您的消息。", "codex": "准备好使用带有 {{model}} 的 Codex。请在下方开始输入您的消息。", "gemini": "准备好使用带有 {{model}} 的 Gemini。请在下方开始输入您的消息。", - "default": "请在上方选择一个提供者以开始" + "default": "请在上方选择一个提供者以开始", + "kiro": "已准备好使用 Kiro 和 {{model}}。在下方输入您的消息。" }, "pressToSearch": "按 {{shortcut}} 搜索会话、文件和提交" }, diff --git a/src/types/app.ts b/src/types/app.ts index 42b9d28b14..3aa3249314 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,4 +1,4 @@ -export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'; +export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'kiro'; export type ProviderModelOption = { value: string;