feat: add VS Code IDE bridge extension

This commit is contained in:
suger
2026-04-09 01:26:18 +08:00
parent 7f694168d0
commit 22480302c3
36 changed files with 3659 additions and 6 deletions

View File

@@ -0,0 +1,36 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run VSCode IDE Bridge",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--new-window",
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "Build VSCode IDE Bridge"
},
{
"name": "Run VSCode IDE Bridge (Open Claude Code Root)",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--new-window",
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}/../.."
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "Build VSCode IDE Bridge"
}
]
}

View File

@@ -0,0 +1,47 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build VSCode IDE Bridge",
"type": "shell",
"command": "bunx",
"args": [
"tsc",
"-p",
"tsconfig.json"
],
"options": {
"cwd": "${workspaceFolder}"
},
"group": "build",
"problemMatcher": "$tsc"
},
{
"label": "Test VSCode IDE Bridge",
"type": "shell",
"command": "bun",
"args": [
"test",
"test"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Package VSCode IDE Bridge",
"type": "shell",
"command": "bun",
"args": [
"run",
"package"
],
"options": {
"cwd": "${workspaceFolder}"
},
"group": "build",
"problemMatcher": []
}
]
}

View File

@@ -0,0 +1,6 @@
src/**
test/**
.vscode/**
tsconfig.json
*.tsbuildinfo
dist/server/**

View File

@@ -0,0 +1,3 @@
UNLICENSED
This package is not licensed for public redistribution.

View File

@@ -0,0 +1,59 @@
# VSCode IDE Bridge
这是一个给当前仓库配套的本地 VSCode 扩展,用来把 VSCode 和现有 Claude Code CLI 的 `ws-ide` 链路接起来。
## 当前能力
- 在本地 `127.0.0.1` 启动 `ws-ide` WebSocket 服务
- 写出 CLI 可发现的 `~/.claude/ide/<port>.lock`
- 把 VSCode 当前活动文件和选区变化发送为 `selection_changed`
- 实现 `openDiff``close_tab``closeAllDiffTabs` 三个 IDE MCP tools
- 提供 `Claude Code Bridge: Restart``Claude Code Bridge: Show Status` 两个调试命令
## 当前限制
- diff 现在支持通过保存右侧文件把修改回传给 CLI但还没有补“未保存直接接受右侧手工编辑”这类更细的交互
- 还没有补 `openFile``getDiagnostics``at_mentioned``log_event` 这些附加能力
- 目前按单个活动 CLI 连接设计,新连接会替换旧连接
## 本地使用
推荐把这个目录单独当成一个扩展工程来打开,而不是总是从 monorepo 根目录调试。
1. 在 VSCode 中直接打开 `packages/vscode-ide-bridge`
2. 打开“运行和调试”
3. 二选一:
- `Run VSCode IDE Bridge`
- `Run VSCode IDE Bridge (Open Claude Code Root)`,会直接在测试窗口里打开 monorepo 根目录
4. 这会自动先执行 `Build VSCode IDE Bridge`
5. 如果用了第一个启动项,就在新开的 Extension Development Host 窗口中再打开你真正要联调的目标工作区
如果用了第二个启动项,会直接打开 `claude-code` 根目录
6. 打开命令面板,执行 `Claude Code Bridge: Show Status`
7. 确认输出中已经出现监听端口和 lockfile 路径
8. 在这个测试窗口的集成终端里启动 Claude Code CLI如果没有自动连上再执行 `/ide`
这个目录自带自己的 VSCode 配置:
- `Run VSCode IDE Bridge`
- `Run VSCode IDE Bridge (Open Claude Code Root)`
- `Build VSCode IDE Bridge`
- `Test VSCode IDE Bridge`
- `Package VSCode IDE Bridge`
如果你仍然从 monorepo 根目录开发,也可以继续使用根目录下的 `.vscode` 配置。
## 打包
可以直接在这个包目录里执行:
```bash
bun run package
```
成功后会在 `dist/vscode-ide-bridge.vsix` 生成可安装的 VSCode 扩展包。
## 验证建议
- 选中一段代码后发起提问,确认 CLI prompt 中出现 `<ide_selection>`
- 触发一次文件 diff确认 VSCode 中会打开 diff并能通过通知选择“接受”或“拒绝”
- 查看 `Claude Code IDE Bridge` output channel确认没有鉴权失败或 lockfile 写入失败

View File

@@ -0,0 +1,59 @@
{
"name": "vscode-ide-bridge",
"private": true,
"version": "0.0.1",
"description": "Local VSCode ws-ide bridge for Claude Code",
"displayName": "Claude Code IDE Bridge",
"publisher": "claude-code-best",
"license": "UNLICENSED",
"type": "module",
"main": "./dist/extension.js",
"repository": {
"type": "git",
"url": "git+https://github.com/claude-code-best/claude-code.git",
"directory": "packages/vscode-ide-bridge"
},
"homepage": "https://github.com/claude-code-best/claude-code/tree/main/packages/vscode-ide-bridge",
"bugs": {
"url": "https://github.com/claude-code-best/claude-code/issues"
},
"categories": [
"Other"
],
"engines": {
"vscode": "^1.90.0"
},
"activationEvents": [
"onStartupFinished",
"onCommand:claudeCodeBridge.restart",
"onCommand:claudeCodeBridge.showStatus"
],
"contributes": {
"commands": [
{
"command": "claudeCodeBridge.restart",
"title": "Claude Code Bridge: Restart"
},
{
"command": "claudeCodeBridge.showStatus",
"title": "Claude Code Bridge: Show Status"
}
]
},
"scripts": {
"build": "bunx tsc -p tsconfig.json",
"bundle": "bun build ./src/extension.ts --outdir dist --target node --format esm --external vscode",
"test": "bun test",
"check": "bunx tsc -p tsconfig.json --pretty false",
"package": "bun run bundle && bunx @vscode/vsce package --no-dependencies --out dist/vscode-ide-bridge.vsix"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"ws": "^8.20.0"
},
"devDependencies": {
"@vscode/vsce": "^3.7.0",
"@types/bun": "^1.3.11",
"typescript": "^6.0.2"
}
}

View File

@@ -0,0 +1,61 @@
import * as vscode from 'vscode'
import { LocalIdeBridgeService } from './server/localIdeBridgeService.js'
let bridgeService: LocalIdeBridgeService | null = null
export async function activate(context: any): Promise<void> {
const outputChannel = vscode.window.createOutputChannel(
'Claude Code IDE Bridge',
)
bridgeService = new LocalIdeBridgeService(
vscode,
outputChannel,
context.environmentVariableCollection,
)
await bridgeService.start()
context.subscriptions.push(
outputChannel,
{
dispose: () => {
void bridgeService?.dispose()
},
},
vscode.commands.registerCommand('claudeCodeBridge.restart', async () => {
await bridgeService?.restart()
const status = bridgeService?.getStatus()
vscode.window.showInformationMessage(
`Claude Code Bridge 已重启${status?.port ? `,端口 ${status.port}` : ''}`,
)
}),
vscode.commands.registerCommand('claudeCodeBridge.showStatus', () => {
const status = bridgeService?.getStatus()
outputChannel.show(true)
outputChannel.appendLine(
`[status] port=${status?.port ?? 'n/a'} connected=${String(status?.hasConnectedClient ?? false)} cliPid=${status?.connectedCliPid ?? 'n/a'} lockfile=${status?.lockfilePath ?? 'n/a'}`,
)
vscode.window.showInformationMessage(
status?.port
? `Claude Code Bridge 正在监听 127.0.0.1:${status.port}`
: 'Claude Code Bridge 尚未启动',
)
}),
vscode.window.onDidChangeTextEditorSelection(() => {
void bridgeService?.publishActiveSelection()
}),
vscode.window.onDidChangeActiveTextEditor(() => {
void bridgeService?.publishActiveSelection()
}),
vscode.workspace.onDidChangeWorkspaceFolders(() => {
void bridgeService?.refreshLockfile()
}),
)
await bridgeService.publishActiveSelection()
}
export async function deactivate(): Promise<void> {
await bridgeService?.dispose()
bridgeService = null
}

View File

@@ -0,0 +1,139 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import {
CallToolRequestSchema,
type CallToolResult,
ListToolsRequestSchema,
type Tool,
} from '@modelcontextprotocol/sdk/types.js'
import type { SelectionChangedParams } from './selectionPublisher.js'
import {
CloseAllDiffTabsArgumentsSchema,
CloseTabArgumentsSchema,
IdeConnectedNotificationSchema,
OpenDiffArgumentsSchema,
type CloseTabArguments,
type OpenDiffArguments,
} from './protocol.js'
export type DiffController = {
openDiff(args: OpenDiffArguments): Promise<CallToolResult>
closeTab(args: CloseTabArguments): Promise<CallToolResult>
closeAllDiffTabs(): Promise<CallToolResult>
}
type CreateIdeBridgeServerOptions = {
diffController: DiffController
}
const IDE_BRIDGE_TOOLS: Tool[] = [
{
name: 'openDiff',
description: 'Open a diff view in the IDE and resolve when the user acts.',
inputSchema: {
type: 'object',
properties: {
old_file_path: { type: 'string' },
new_file_path: { type: 'string' },
new_file_contents: { type: 'string' },
tab_name: { type: 'string' },
},
required: [
'old_file_path',
'new_file_path',
'new_file_contents',
'tab_name',
],
additionalProperties: false,
},
},
{
name: 'close_tab',
description: 'Close a previously opened IDE tab by Claude Code tab name.',
inputSchema: {
type: 'object',
properties: {
tab_name: { type: 'string' },
},
required: ['tab_name'],
additionalProperties: false,
},
},
{
name: 'closeAllDiffTabs',
description: 'Close all diff tabs created by the IDE bridge.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
]
export function createIdeBridgeServer(options: CreateIdeBridgeServerOptions): {
server: Server
notifySelectionChanged(params: SelectionChangedParams): Promise<void>
getConnectedCliPid(): number | null
} {
const server = new Server(
{
name: 'claude-code-vscode-ide-bridge',
version: '0.0.1',
},
{
capabilities: {
tools: {},
},
},
)
let connectedCliPid: number | null = null
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: IDE_BRIDGE_TOOLS,
}
})
server.setRequestHandler(CallToolRequestSchema, async request => {
switch (request.params.name) {
case 'openDiff':
return options.diffController.openDiff(
OpenDiffArgumentsSchema.parse(request.params.arguments ?? {}),
)
case 'close_tab':
return options.diffController.closeTab(
CloseTabArgumentsSchema.parse(request.params.arguments ?? {}),
)
case 'closeAllDiffTabs':
CloseAllDiffTabsArgumentsSchema.parse(request.params.arguments ?? {})
return options.diffController.closeAllDiffTabs()
default:
return {
isError: true,
content: [
{
type: 'text',
text: `Unsupported IDE tool: ${request.params.name}`,
},
],
}
}
})
server.setNotificationHandler(IdeConnectedNotificationSchema, notification => {
connectedCliPid = notification.params.pid
})
return {
server,
async notifySelectionChanged(params) {
await server.notification({
method: 'selection_changed',
params,
})
},
getConnectedCliPid() {
return connectedCliPid
},
}
}

View File

@@ -0,0 +1,350 @@
import { readFile } from 'node:fs/promises'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import * as vscode from 'vscode'
import type { DiffController } from './bridgeServer.js'
import type { OpenDiffArguments } from './protocol.js'
const DIFF_SCHEME = 'claude-code-bridge'
const ACCEPT_LABEL = '接受'
const REJECT_LABEL = '拒绝'
type DiffSession = {
tabName: string
leftUri: any
rightUri: any
filePath: string
hasBeenVisible: boolean
settled: boolean
resolve: (result: CallToolResult) => void
}
class VirtualDocumentProvider {
private readonly contents = new Map<string, string>()
provideTextDocumentContent(uri: any): string {
return this.contents.get(uri.toString()) ?? ''
}
set(uri: any, content: string): void {
this.contents.set(uri.toString(), content)
}
delete(uri: any): void {
this.contents.delete(uri.toString())
}
}
function createTextResult(text: string): CallToolResult {
return {
content: [
{
type: 'text',
text,
},
],
}
}
function createFileSavedResult(contents: string): CallToolResult {
return {
content: [
{
type: 'text',
text: 'FILE_SAVED',
},
{
type: 'text',
text: contents,
},
],
}
}
function buildDiffUri(kind: 'left' | 'right', tabName: string, filePath: string) {
return vscode.Uri.parse(
`${DIFF_SCHEME}:/${kind}/${encodeURIComponent(tabName)}?filePath=${encodeURIComponent(filePath)}`,
)
}
function getDocumentFullRange(document: any): any {
const lineCount = Math.max(document?.lineCount ?? 1, 1)
const lastLine = document?.lineAt?.(lineCount - 1)
const lastCharacter = lastLine?.text?.length ?? 0
return new vscode.Range(0, 0, lineCount - 1, lastCharacter)
}
async function replaceDocumentContents(
editor: any,
nextContent: string,
): Promise<void> {
const currentContent = editor?.document?.getText?.() ?? ''
if (currentContent === nextContent) {
return
}
await editor.edit((editBuilder: any) => {
editBuilder.replace(
getDocumentFullRange(editor.document),
nextContent,
)
})
}
function matchesSessionDocument(session: DiffSession, document: any): boolean {
const uriString = document?.uri?.toString?.()
const fsPath = document?.uri?.fsPath
return (
uriString === session.rightUri.toString() ||
(typeof fsPath === 'string' && fsPath === session.filePath)
)
}
export function createDiffController(outputChannel: any): DiffController & {
dispose(): Promise<void>
} {
const provider = new VirtualDocumentProvider()
const sessions = new Map<string, DiffSession>()
const providerDisposable =
vscode.workspace.registerTextDocumentContentProvider(
DIFF_SCHEME,
provider,
)
const visibilityDisposable = vscode.window.onDidChangeVisibleTextEditors(
(editors: any[]) => {
const visibleUris = new Set(
editors.map(editor => editor?.document?.uri?.toString?.()),
)
for (const session of sessions.values()) {
const leftVisible = visibleUris.has(session.leftUri.toString())
const rightVisible = visibleUris.has(session.rightUri.toString())
if (leftVisible || rightVisible) {
session.hasBeenVisible = true
continue
}
if (session.hasBeenVisible) {
void settleSession(
session.tabName,
createTextResult('TAB_CLOSED'),
false,
)
}
}
},
)
const saveDisposable = vscode.workspace.onDidSaveTextDocument(
(document: any) => {
for (const session of sessions.values()) {
if (!matchesSessionDocument(session, document)) {
continue
}
void settleSession(
session.tabName,
createFileSavedResult(document.getText()),
true,
)
}
},
)
async function settleSession(
tabName: string,
result: CallToolResult,
closeEditors: boolean,
): Promise<void> {
const session = sessions.get(tabName)
if (!session || session.settled) {
return
}
session.settled = true
sessions.delete(tabName)
provider.delete(session.leftUri)
provider.delete(session.rightUri)
if (closeEditors) {
await closeSessionEditors(session).catch(() => {})
}
session.resolve(result)
}
async function closeSessionEditors(session: DiffSession): Promise<void> {
for (const editor of vscode.window.visibleTextEditors ?? []) {
if (
matchesSessionDocument(session, editor?.document) &&
editor?.document?.isDirty
) {
await vscode.window.showTextDocument(editor.document, {
preview: false,
preserveFocus: false,
viewColumn: editor.viewColumn,
})
await vscode.commands.executeCommand('workbench.action.files.revert')
}
}
const matchedTabs: any[] = []
for (const group of vscode.window.tabGroups?.all ?? []) {
for (const tab of group.tabs ?? []) {
const original = tab?.input?.original?.toString?.()
const modified = tab?.input?.modified?.toString?.()
const uri = tab?.input?.uri?.toString?.()
if (
original === session.leftUri.toString() ||
modified === session.rightUri.toString() ||
uri === session.rightUri.toString() ||
tab?.input?.uri?.fsPath === session.filePath ||
tab?.label === session.tabName
) {
matchedTabs.push(tab)
}
}
}
if (matchedTabs.length > 0 && vscode.window.tabGroups?.close) {
await vscode.window.tabGroups.close(matchedTabs, true)
return
}
for (const editor of vscode.window.visibleTextEditors ?? []) {
const uri = editor?.document?.uri?.toString?.()
if (
uri === session.leftUri.toString() ||
uri === session.rightUri.toString()
) {
await vscode.window.showTextDocument(editor.document, {
preview: false,
preserveFocus: false,
viewColumn: editor.viewColumn,
})
await vscode.commands.executeCommand('workbench.action.closeActiveEditor')
}
}
}
return {
async openDiff(args: OpenDiffArguments): Promise<CallToolResult> {
await settleSession(args.tab_name, createTextResult('TAB_CLOSED'), true)
const leftContent = await readFile(args.old_file_path, 'utf8').catch(
() => '',
)
const leftUri = buildDiffUri('left', args.tab_name, args.old_file_path)
const rightUri = vscode.Uri.file(args.new_file_path)
provider.set(leftUri, leftContent)
const rightDocument = await vscode.workspace.openTextDocument(rightUri)
const rightEditor = await vscode.window.showTextDocument(rightDocument, {
preview: false,
preserveFocus: true,
})
await replaceDocumentContents(rightEditor, args.new_file_contents)
const resultPromise = new Promise<CallToolResult>(resolve => {
sessions.set(args.tab_name, {
tabName: args.tab_name,
leftUri,
rightUri,
filePath: args.new_file_path,
hasBeenVisible: false,
settled: false,
resolve,
})
})
outputChannel.appendLine(
`[diff] open ${args.tab_name} -> ${args.new_file_path}`,
)
await vscode.commands.executeCommand(
'vscode.diff',
leftUri,
rightUri,
args.tab_name,
{
preview: false,
},
)
queueMicrotask(() => {
const visibleUris = new Set(
(vscode.window.visibleTextEditors ?? []).map((editor: any) =>
editor?.document?.uri?.toString?.(),
),
)
const session = sessions.get(args.tab_name)
if (!session) {
return
}
if (
visibleUris.has(session.leftUri.toString()) ||
visibleUris.has(session.rightUri.toString())
) {
session.hasBeenVisible = true
}
})
void vscode.window
.showInformationMessage(
`Claude Code 提议了对 ${args.new_file_path} 的修改`,
ACCEPT_LABEL,
REJECT_LABEL,
)
.then((choice: string | undefined) => {
if (choice === ACCEPT_LABEL) {
void settleSession(
args.tab_name,
createTextResult('TAB_CLOSED'),
true,
)
} else if (choice === REJECT_LABEL) {
void settleSession(
args.tab_name,
createTextResult('DIFF_REJECTED'),
true,
)
}
})
return resultPromise
},
async closeTab(args): Promise<CallToolResult> {
const session = sessions.get(args.tab_name)
if (session) {
await closeSessionEditors(session).catch(() => {})
await settleSession(args.tab_name, createTextResult('TAB_CLOSED'), false)
}
return createTextResult('TAB_CLOSED')
},
async closeAllDiffTabs(): Promise<CallToolResult> {
for (const tabName of [...sessions.keys()]) {
const session = sessions.get(tabName)
if (!session) {
continue
}
await closeSessionEditors(session).catch(() => {})
await settleSession(tabName, createTextResult('TAB_CLOSED'), false)
}
return createTextResult('OK')
},
async dispose(): Promise<void> {
visibilityDisposable.dispose()
saveDisposable.dispose()
providerDisposable.dispose()
await this.closeAllDiffTabs()
},
}
}

View File

@@ -0,0 +1,231 @@
import { WebSocketServer } from 'ws'
import { createIdeBridgeServer } from './bridgeServer.js'
import { createDiffController } from './diffController.js'
import {
buildLockfilePayload,
removeLockfile,
writeLockfile,
} from './lockfile.js'
import { createAuthToken } from './randomToken.js'
import { ServerWebSocketTransport } from './serverWebSocketTransport.js'
import {
clearClaudeCodeIdePort,
setClaudeCodeIdePort,
} from './terminalEnvironment.js'
import { getActiveSelectionSnapshot, getWorkspaceFolderPaths } from './workspaceInfo.js'
type BridgeStatus = {
port: number | null
lockfilePath: string | null
hasConnectedClient: boolean
connectedCliPid: number | null
workspaceFolders: string[]
lastSelectionSentAt: string | null
}
type ActiveConnection = {
socket: any
bridge: ReturnType<typeof createIdeBridgeServer>
transport: ServerWebSocketTransport
}
export class LocalIdeBridgeService {
private readonly diffController
private readonly ideName = 'VS Code'
private readonly runningInWindows = process.platform === 'win32'
private server: any | null = null
private port: number | null = null
private lockfilePath: string | null = null
private authToken = ''
private activeConnection: ActiveConnection | null = null
private lastSelectionSentAt: string | null = null
private disposed = false
constructor(
private readonly vscode: any,
private readonly outputChannel: any,
private readonly environmentVariableCollection?: {
replace(name: string, value: string): void
delete(name: string): void
},
) {
this.diffController = createDiffController(outputChannel)
}
async start(): Promise<void> {
if (this.server || this.disposed) {
return
}
this.authToken = createAuthToken()
this.server = await this.createWebSocketServer()
this.port = this.getServerPort()
await this.refreshLockfile()
this.outputChannel.appendLine(
`[bridge] listening on ws://127.0.0.1:${this.port}`,
)
}
async restart(): Promise<void> {
await this.stop()
this.disposed = false
await this.start()
}
async refreshLockfile(): Promise<void> {
if (!this.port) {
return
}
setClaudeCodeIdePort(this.environmentVariableCollection, this.port)
await removeLockfile(this.lockfilePath)
this.lockfilePath = await writeLockfile(
this.port,
buildLockfilePayload({
pid: process.pid,
ideName: this.ideName,
workspaceFolders: getWorkspaceFolderPaths(
this.vscode.workspace.workspaceFolders,
),
authToken: this.authToken,
runningInWindows: this.runningInWindows,
}),
)
this.outputChannel.appendLine(`[bridge] lockfile -> ${this.lockfilePath}`)
this.outputChannel.appendLine(
`[bridge] terminal env CLAUDE_CODE_SSE_PORT=${this.port}`,
)
}
async publishActiveSelection(): Promise<void> {
if (!this.activeConnection) {
return
}
const snapshot = getActiveSelectionSnapshot(this.vscode.window.activeTextEditor)
if (!snapshot.selection && !snapshot.filePath) {
return
}
await this.activeConnection.bridge.notifySelectionChanged(snapshot)
this.lastSelectionSentAt = new Date().toISOString()
}
getStatus(): BridgeStatus {
return {
port: this.port,
lockfilePath: this.lockfilePath,
hasConnectedClient: this.activeConnection !== null,
connectedCliPid:
this.activeConnection?.bridge.getConnectedCliPid() ?? null,
workspaceFolders: getWorkspaceFolderPaths(
this.vscode.workspace.workspaceFolders,
),
lastSelectionSentAt: this.lastSelectionSentAt,
}
}
async stop(): Promise<void> {
await this.closeActiveConnection()
if (this.server) {
await new Promise<void>(resolve => {
this.server?.close(() => resolve())
})
this.server = null
}
await removeLockfile(this.lockfilePath)
clearClaudeCodeIdePort(this.environmentVariableCollection)
this.lockfilePath = null
this.port = null
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
await this.stop()
await this.diffController.dispose()
}
private async createWebSocketServer(): Promise<any> {
const server = new WebSocketServer({
host: '127.0.0.1',
port: 0,
})
await new Promise<void>((resolve, reject) => {
server.once('listening', () => resolve())
server.once('error', (error: Error) => reject(error))
})
server.on('connection', (socket: any, request: any) => {
const authHeader = request.headers['x-claude-code-ide-authorization']
if (authHeader !== this.authToken) {
this.outputChannel.appendLine('[bridge] rejected unauthorized client')
socket.close(4003, 'unauthorized')
return
}
void this.handleConnection(socket)
})
return server
}
private getServerPort(): number {
const address = this.server?.address()
if (!address || typeof address === 'string') {
throw new Error('Unable to determine bridge port')
}
return address.port
}
private async handleConnection(socket: any): Promise<void> {
await this.closeActiveConnection()
const bridge = createIdeBridgeServer({
diffController: this.diffController,
})
const transport = new ServerWebSocketTransport(socket)
socket.on('close', () => {
if (this.activeConnection?.socket === socket) {
this.activeConnection = null
}
})
await bridge.server.connect(transport)
this.activeConnection = {
socket,
bridge,
transport,
}
this.outputChannel.appendLine('[bridge] CLI client connected')
await this.publishActiveSelection().catch(error => {
this.outputChannel.appendLine(
`[bridge] failed to publish initial selection: ${(error as Error).message}`,
)
})
}
private async closeActiveConnection(): Promise<void> {
if (!this.activeConnection) {
return
}
const connection = this.activeConnection
this.activeConnection = null
await connection.transport.close().catch(() => {})
}
}

View File

@@ -0,0 +1,56 @@
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { LockfilePayload } from './protocol.js'
type BuildLockfilePayloadInput = {
pid: number
ideName: string
workspaceFolders: string[]
authToken: string
runningInWindows: boolean
}
function getClaudeConfigDir(): string {
return (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')).normalize(
'NFC',
)
}
export function buildLockfilePayload(
input: BuildLockfilePayloadInput,
): LockfilePayload {
return {
workspaceFolders: input.workspaceFolders,
pid: input.pid,
ideName: input.ideName,
transport: 'ws',
runningInWindows: input.runningInWindows,
authToken: input.authToken,
}
}
export function getLockfileDir(): string {
return join(getClaudeConfigDir(), 'ide')
}
export function getLockfilePath(port: number): string {
return join(getLockfileDir(), `${port}.lock`)
}
export async function writeLockfile(
port: number,
payload: LockfilePayload,
): Promise<string> {
const lockfilePath = getLockfilePath(port)
await mkdir(getLockfileDir(), { recursive: true })
await writeFile(lockfilePath, JSON.stringify(payload), 'utf8')
return lockfilePath
}
export async function removeLockfile(lockfilePath: string | null): Promise<void> {
if (!lockfilePath) {
return
}
await rm(lockfilePath, { force: true })
}

View File

@@ -0,0 +1,33 @@
import { z } from 'zod/v4'
export type LockfilePayload = {
workspaceFolders: string[]
pid: number
ideName: string
transport: 'ws'
runningInWindows: boolean
authToken: string
}
export const OpenDiffArgumentsSchema = z.object({
old_file_path: z.string(),
new_file_path: z.string(),
new_file_contents: z.string(),
tab_name: z.string(),
})
export const CloseTabArgumentsSchema = z.object({
tab_name: z.string(),
})
export const CloseAllDiffTabsArgumentsSchema = z.object({})
export const IdeConnectedNotificationSchema = z.object({
method: z.literal('ide_connected'),
params: z.object({
pid: z.number(),
}),
})
export type OpenDiffArguments = z.infer<typeof OpenDiffArgumentsSchema>
export type CloseTabArguments = z.infer<typeof CloseTabArgumentsSchema>

View File

@@ -0,0 +1,5 @@
import { randomBytes } from 'node:crypto'
export function createAuthToken(): string {
return randomBytes(24).toString('hex')
}

View File

@@ -0,0 +1,41 @@
export type SelectionPoint = {
line: number
character: number
}
export type SelectionChangedParams = {
selection: {
start: SelectionPoint
end: SelectionPoint
} | null
text?: string
filePath?: string
}
type BuildSelectionChangedParamsInput = {
filePath?: string
text?: string
start?: SelectionPoint
end?: SelectionPoint
}
export function buildSelectionChangedParams(
input: BuildSelectionChangedParamsInput,
): SelectionChangedParams {
if (!input.start || !input.end) {
return {
selection: null,
text: input.text,
filePath: input.filePath,
}
}
return {
selection: {
start: input.start,
end: input.end,
},
text: input.text,
filePath: input.filePath,
}
}

View File

@@ -0,0 +1,92 @@
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import {
type JSONRPCMessage,
JSONRPCMessageSchema,
} from '@modelcontextprotocol/sdk/types.js'
type WebSocketLike = {
readyState: number
send(data: string, callback?: (error?: Error) => void): void
close(): void
on(event: 'message', listener: (data: Buffer | string) => void): void
on(event: 'close', listener: () => void): void
on(event: 'error', listener: (error: Error) => void): void
off(event: 'message', listener: (data: Buffer | string) => void): void
off(event: 'close', listener: () => void): void
off(event: 'error', listener: (error: Error) => void): void
}
const WS_OPEN = 1
export class ServerWebSocketTransport implements Transport {
private started = false
constructor(private readonly socket: WebSocketLike) {
this.socket.on('message', this.handleMessage)
this.socket.on('close', this.handleClose)
this.socket.on('error', this.handleError)
}
onclose?: () => void
onerror?: (error: Error) => void
onmessage?: (message: JSONRPCMessage) => void
async start(): Promise<void> {
if (this.started) {
throw new Error('Start can only be called once per transport.')
}
if (this.socket.readyState !== WS_OPEN) {
throw new Error('WebSocket is not open. Cannot start transport.')
}
this.started = true
}
async send(message: JSONRPCMessage): Promise<void> {
if (this.socket.readyState !== WS_OPEN) {
throw new Error('WebSocket is not open. Cannot send message.')
}
await new Promise<void>((resolve, reject) => {
this.socket.send(JSON.stringify(message), error => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
async close(): Promise<void> {
if (this.socket.readyState === WS_OPEN) {
this.socket.close()
return
}
this.cleanup()
}
private handleMessage = (data: Buffer | string) => {
try {
const raw = typeof data === 'string' ? data : data.toString('utf8')
const parsed = JSONRPCMessageSchema.parse(JSON.parse(raw))
this.onmessage?.(parsed)
} catch (error) {
this.handleError(error instanceof Error ? error : new Error(String(error)))
}
}
private handleClose = () => {
this.cleanup()
this.onclose?.()
}
private handleError = (error: Error) => {
this.onerror?.(error)
}
private cleanup() {
this.socket.off('message', this.handleMessage)
this.socket.off('close', this.handleClose)
this.socket.off('error', this.handleError)
}
}

View File

@@ -0,0 +1,19 @@
type EnvironmentVariableCollectionLike = {
replace(name: string, value: string): void
delete(name: string): void
}
const CLAUDE_CODE_SSE_PORT = 'CLAUDE_CODE_SSE_PORT'
export function setClaudeCodeIdePort(
collection: EnvironmentVariableCollectionLike | undefined,
port: number,
): void {
collection?.replace(CLAUDE_CODE_SSE_PORT, String(port))
}
export function clearClaudeCodeIdePort(
collection: EnvironmentVariableCollectionLike | undefined,
): void {
collection?.delete(CLAUDE_CODE_SSE_PORT)
}

View File

@@ -0,0 +1,53 @@
import { buildSelectionChangedParams } from './selectionPublisher.js'
type WorkspaceFolderLike = {
uri?: {
fsPath?: string
}
}
type EditorLike = {
document?: {
uri?: {
fsPath?: string
}
getText(selection: unknown): string
}
selection?: {
start: {
line: number
character: number
}
end: {
line: number
character: number
}
isEmpty?: boolean
}
}
export function getWorkspaceFolderPaths(
workspaceFolders: WorkspaceFolderLike[] | undefined,
): string[] {
return (workspaceFolders ?? [])
.map(folder => folder.uri?.fsPath)
.filter((value): value is string => Boolean(value))
}
export function getActiveSelectionSnapshot(editor: EditorLike | undefined) {
const filePath = editor?.document?.uri?.fsPath
const selection = editor?.selection
if (!editor?.document || !selection || selection.isEmpty) {
return buildSelectionChangedParams({
filePath,
})
}
return buildSelectionChangedParams({
filePath,
text: editor.document.getText(selection),
start: selection.start,
end: selection.end,
})
}

View File

@@ -0,0 +1,4 @@
declare module 'vscode' {
const vscode: any
export = vscode
}

View File

@@ -0,0 +1,3 @@
declare module 'ws' {
export const WebSocketServer: any
}

View File

@@ -0,0 +1,135 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { describe, expect, test } from 'bun:test'
import { z } from 'zod/v4'
import { createLinkedTransportPair } from '../../../src/services/mcp/InProcessTransport.js'
import {
createIdeBridgeServer,
type DiffController,
} from '../src/server/bridgeServer.js'
const SelectionChangedSchema = z.object({
method: z.literal('selection_changed'),
params: z.object({
selection: z
.object({
start: z.object({ line: z.number(), character: z.number() }),
end: z.object({ line: z.number(), character: z.number() }),
})
.nullable(),
text: z.string().optional(),
filePath: z.string().optional(),
}),
})
function createTestClient() {
return new Client({
name: 'vscode-ide-bridge-test-client',
version: '0.0.1',
})
}
describe('ide bridge MCP server', () => {
test('lists the bridge tools and delegates openDiff calls', async () => {
const openDiffCalls: Array<Record<string, unknown>> = []
const diffController: DiffController = {
async openDiff(args) {
openDiffCalls.push(args)
return {
content: [{ type: 'text', text: 'TAB_CLOSED' }],
}
},
async closeTab() {
return {
content: [{ type: 'text', text: 'TAB_CLOSED' }],
}
},
async closeAllDiffTabs() {
return {
content: [{ type: 'text', text: 'OK' }],
}
},
}
const bridge = createIdeBridgeServer({ diffController })
const client = createTestClient()
const [clientTransport, serverTransport] = createLinkedTransportPair()
await bridge.server.connect(serverTransport)
await client.connect(clientTransport)
const toolResult = await client.listTools()
expect(toolResult.tools.map(tool => tool.name)).toEqual([
'openDiff',
'close_tab',
'closeAllDiffTabs',
])
const openDiffResult = await client.callTool({
name: 'openDiff',
arguments: {
old_file_path: 'D:/vibe/claude-code/src/cli/print.ts',
new_file_path: 'D:/vibe/claude-code/src/cli/print.ts',
new_file_contents: 'new content',
tab_name: 'tab-1',
},
})
expect(openDiffResult.content[0]).toEqual({
type: 'text',
text: 'TAB_CLOSED',
})
expect(openDiffCalls).toHaveLength(1)
expect(openDiffCalls[0]?.tab_name).toBe('tab-1')
})
test('forwards selection_changed notifications to the connected client', async () => {
const diffController: DiffController = {
async openDiff() {
return {
content: [{ type: 'text', text: 'TAB_CLOSED' }],
}
},
async closeTab() {
return {
content: [{ type: 'text', text: 'TAB_CLOSED' }],
}
},
async closeAllDiffTabs() {
return {
content: [{ type: 'text', text: 'OK' }],
}
},
}
const bridge = createIdeBridgeServer({ diffController })
const client = createTestClient()
const [clientTransport, serverTransport] = createLinkedTransportPair()
await bridge.server.connect(serverTransport)
await client.connect(clientTransport)
const notificationPromise = new Promise<z.infer<typeof SelectionChangedSchema>>(
resolve => {
client.setNotificationHandler(SelectionChangedSchema, notification => {
resolve(notification)
})
},
)
await bridge.notifySelectionChanged({
selection: {
start: { line: 4, character: 2 },
end: { line: 6, character: 0 },
},
text: 'selected text',
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
})
const notification = await notificationPromise
expect(notification.params.filePath).toBe(
'D:/vibe/claude-code/src/cli/print.ts',
)
expect(notification.params.text).toBe('selected text')
expect(notification.params.selection?.start.line).toBe(4)
})
})

View File

@@ -0,0 +1,247 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, mock, test } from 'bun:test'
type FakeUri = {
scheme: string
fsPath: string
path: string
query: string
toString(): string
}
type FakeDocument = {
uri: FakeUri
isDirty: boolean
lineCount: number
lineAt(index: number): { text: string }
getText(): string
setText(next: string): void
}
function createFakeUri(
scheme: string,
fsPath: string,
query = '',
): FakeUri {
const normalizedFsPath = fsPath.replaceAll('\\', '/')
return {
scheme,
fsPath,
path: fsPath,
query,
toString() {
if (scheme === 'file') {
return `file://${normalizedFsPath}`
}
return `${scheme}:/${normalizedFsPath}${query ? `?${query}` : ''}`
},
}
}
function createFakeVscode() {
const documents = new Map<string, FakeDocument>()
const saveListeners = new Set<(document: FakeDocument) => void>()
const visibleEditorListeners = new Set<(editors: any[]) => void>()
const visibleTextEditors: any[] = []
function createDocument(uri: FakeUri, initialText = ''): FakeDocument {
let text = initialText
return {
uri,
isDirty: false,
get lineCount() {
return Math.max(text.split('\n').length, 1)
},
lineAt(index: number) {
return {
text: text.split('\n')[index] ?? '',
}
},
getText() {
return text
},
setText(next: string) {
text = next
this.isDirty = true
},
}
}
const vscode = {
Uri: {
parse(value: string) {
const match = value.match(/^([a-z-]+):\/(.+?)(?:\?(.*))?$/i)
if (!match) {
throw new Error(`Unsupported URI: ${value}`)
}
const [, scheme, path, query = ''] = match
return createFakeUri(
scheme,
decodeURIComponent(path),
query,
)
},
file(filePath: string) {
return createFakeUri('file', filePath)
},
},
Range: class {
constructor(
public startLine: number,
public startCharacter: number,
public endLine: number,
public endCharacter: number,
) {}
},
workspace: {
registerTextDocumentContentProvider() {
return { dispose() {} }
},
onDidSaveTextDocument(handler: (document: FakeDocument) => void) {
saveListeners.add(handler)
return {
dispose() {
saveListeners.delete(handler)
},
}
},
async openTextDocument(uri: FakeUri) {
const key = uri.toString()
const existing = documents.get(key)
if (existing) {
return existing
}
const doc = createDocument(uri)
documents.set(key, doc)
return doc
},
},
window: {
visibleTextEditors,
tabGroups: {
all: [],
async close() {},
},
onDidChangeVisibleTextEditors(handler: (editors: any[]) => void) {
visibleEditorListeners.add(handler)
return {
dispose() {
visibleEditorListeners.delete(handler)
},
}
},
async showTextDocument(document: FakeDocument) {
const editor = {
document,
viewColumn: 1,
async edit(
callback: (editBuilder: { replace(range: unknown, text: string): void }) => void,
) {
callback({
replace(_range, text) {
document.setText(text)
},
})
return true
},
}
if (!visibleTextEditors.includes(editor)) {
visibleTextEditors.splice(0, visibleTextEditors.length, editor)
for (const listener of visibleEditorListeners) {
listener([...visibleTextEditors])
}
}
return editor
},
async showInformationMessage() {
return undefined
},
},
commands: {
async executeCommand() {},
},
__documents: documents,
async __emitSave(document: FakeDocument) {
document.isDirty = false
for (const listener of saveListeners) {
listener(document)
}
},
}
return vscode
}
async function waitForDocument(
filePath: string,
attempts = 20,
): Promise<FakeDocument | undefined> {
for (let i = 0; i < attempts; i++) {
const document = fakeVscode.__documents.get(
fakeVscode.Uri.file(filePath).toString(),
)
if (document) {
return document
}
await new Promise(resolve => setTimeout(resolve, 10))
}
return undefined
}
const fakeVscode = createFakeVscode()
mock.module('vscode', () => fakeVscode)
afterEach(() => {
fakeVscode.__documents.clear()
fakeVscode.window.visibleTextEditors.splice(
0,
fakeVscode.window.visibleTextEditors.length,
)
})
describe('diff controller', () => {
test('returns FILE_SAVED with the saved file contents', async () => {
const { createDiffController } = await import(
'../src/server/diffController.js'
)
const tempDir = mkdtempSync(join(tmpdir(), 'claude-code-bridge-'))
const filePath = join(tempDir, 'sample.ts')
writeFileSync(filePath, 'const before = true\n')
const controller = createDiffController({
appendLine() {},
})
const resultPromise = controller.openDiff({
old_file_path: filePath,
new_file_path: filePath,
new_file_contents: 'const proposed = true\n',
tab_name: 'sample.ts',
})
const savedDocument = await waitForDocument(filePath)
expect(savedDocument).toBeDefined()
savedDocument?.setText('const saved = true\n')
await fakeVscode.__emitSave(savedDocument as FakeDocument)
const result = await Promise.race([
resultPromise,
new Promise(resolve =>
setTimeout(() => resolve('timed-out'), 200),
),
])
expect(result).toEqual({
content: [
{ type: 'text', text: 'FILE_SAVED' },
{ type: 'text', text: 'const saved = true\n' },
],
})
await controller.dispose()
})
})

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test'
import {
buildLockfilePayload,
getLockfilePath,
} from '../src/server/lockfile.js'
describe('lockfile helpers', () => {
test('builds a ws-ide lockfile payload with auth token and workspace folders', () => {
const payload = buildLockfilePayload({
pid: 123,
ideName: 'VS Code',
workspaceFolders: ['D:/vibe/claude-code'],
authToken: 'token-123',
runningInWindows: true,
})
expect(payload.transport).toBe('ws')
expect(payload.authToken).toBe('token-123')
expect(payload.workspaceFolders).toEqual(['D:/vibe/claude-code'])
expect(payload.pid).toBe(123)
})
test('derives the lockfile path from CLAUDE_CONFIG_DIR when provided', () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = 'D:/tmp/claude-config'
try {
expect(getLockfilePath(4567)).toBe(
'D:\\tmp\\claude-config\\ide\\4567.lock',
)
} finally {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
}
})
})

View File

@@ -0,0 +1,32 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
const packageRoot = join(import.meta.dir, '..')
const packageJsonPath = join(packageRoot, 'package.json')
describe('vscode-ide-bridge package', () => {
test('declares a VSCode extension entry', () => {
expect(existsSync(packageJsonPath)).toBe(true)
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
main?: string
engines?: { vscode?: string }
activationEvents?: string[]
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
expect(packageJson.main).toBe('./dist/extension.js')
expect(packageJson.engines?.vscode).toBeDefined()
expect(packageJson.activationEvents).toContain('onStartupFinished')
expect(packageJson.dependencies).toMatchObject({
'@modelcontextprotocol/sdk': expect.any(String),
ws: expect.any(String),
})
expect(packageJson.devDependencies).toMatchObject({
'@types/bun': expect.any(String),
typescript: expect.any(String),
})
})
})

View File

@@ -0,0 +1,71 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
type PackageJson = {
displayName?: string
publisher?: string
license?: string
scripts?: Record<string, string>
}
type TaskConfig = {
label?: string
command?: string
args?: string[]
}
const packageRoot = join(import.meta.dir, '..')
const packageJsonPath = join(packageRoot, 'package.json')
const tasksJsonPath = join(packageRoot, '.vscode', 'tasks.json')
const vscodeIgnorePath = join(packageRoot, '.vscodeignore')
const readmePath = join(packageRoot, 'README.md')
describe('vscode-ide-bridge packaging workflow', () => {
test('declares the metadata and script needed to package a .vsix', () => {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageJson
expect(packageJson.displayName).toBe('Claude Code IDE Bridge')
expect(packageJson.publisher).toBe('claude-code-best')
expect(packageJson.license).toBeDefined()
expect(packageJson.scripts?.bundle).toBe(
'bun build ./src/extension.ts --outdir dist --target node --format esm --external vscode',
)
expect(packageJson.scripts?.package).toBe(
'bun run bundle && bunx @vscode/vsce package --no-dependencies --out dist/vscode-ide-bridge.vsix',
)
})
test('declares a package-local task for building a .vsix', () => {
expect(existsSync(tasksJsonPath)).toBe(true)
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
tasks?: TaskConfig[]
}
const packageTask = tasksJson.tasks?.find(
item => item.label === 'Package VSCode IDE Bridge',
)
expect(packageTask).toBeDefined()
expect(packageTask?.command).toBe('bun')
expect(packageTask?.args).toEqual(['run', 'package'])
})
test('excludes development-only files from the packaged extension', () => {
expect(existsSync(vscodeIgnorePath)).toBe(true)
const contents = readFileSync(vscodeIgnorePath, 'utf8')
expect(contents).toContain('src/**')
expect(contents).toContain('test/**')
expect(contents).toContain('tsconfig.json')
})
test('keeps the packaged README free of local absolute file links', () => {
const contents = readFileSync(readmePath, 'utf8')
expect(contents).not.toContain('](/')
expect(contents).not.toContain(':/')
})
})

View File

@@ -0,0 +1,89 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
type LaunchConfig = {
name?: string
type?: string
request?: string
preLaunchTask?: string
args?: string[]
}
type TaskConfig = {
label?: string
command?: string
args?: string[]
}
const packageRoot = join(import.meta.dir, '..')
const launchJsonPath = join(packageRoot, '.vscode', 'launch.json')
const tasksJsonPath = join(packageRoot, '.vscode', 'tasks.json')
describe('standalone package workspace workflow', () => {
test('declares a package-local extension host launch config', () => {
expect(existsSync(launchJsonPath)).toBe(true)
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
configurations?: LaunchConfig[]
}
const config = launchJson.configurations?.find(
item => item.name === 'Run VSCode IDE Bridge',
)
expect(config).toBeDefined()
expect(config?.type).toBe('extensionHost')
expect(config?.request).toBe('launch')
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
expect(config?.args).toContain('--new-window')
expect(config?.args).toContain('--disable-extensions')
expect(config?.args).toContain(
'--extensionDevelopmentPath=${workspaceFolder}',
)
})
test('declares a launch config that opens the claude-code workspace root', () => {
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
configurations?: LaunchConfig[]
}
const config = launchJson.configurations?.find(
item => item.name === 'Run VSCode IDE Bridge (Open Claude Code Root)',
)
expect(config).toBeDefined()
expect(config?.type).toBe('extensionHost')
expect(config?.request).toBe('launch')
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
expect(config?.args).toContain('--new-window')
expect(config?.args).toContain('--disable-extensions')
expect(config?.args).toContain(
'--extensionDevelopmentPath=${workspaceFolder}',
)
expect(config?.args).toContain('${workspaceFolder}/../..')
})
test('declares package-local build and test tasks', () => {
expect(existsSync(tasksJsonPath)).toBe(true)
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
tasks?: TaskConfig[]
}
const buildTask = tasksJson.tasks?.find(
item => item.label === 'Build VSCode IDE Bridge',
)
const testTask = tasksJson.tasks?.find(
item => item.label === 'Test VSCode IDE Bridge',
)
expect(buildTask).toBeDefined()
expect(buildTask?.command).toBe('bunx')
expect(buildTask?.args).toEqual(['tsc', '-p', 'tsconfig.json'])
expect(testTask).toBeDefined()
expect(testTask?.command).toBe('bun')
expect(testTask?.args).toEqual(['test', 'test'])
})
})

View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from 'bun:test'
import { buildSelectionChangedParams } from '../src/server/selectionPublisher.js'
describe('selection publisher helpers', () => {
test('serializes a selected range with text and file path', () => {
const params = buildSelectionChangedParams({
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
text: 'const value = 1',
start: { line: 10, character: 2 },
end: { line: 10, character: 17 },
})
expect(params.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
expect(params.text).toBe('const value = 1')
expect(params.selection?.start.line).toBe(10)
expect(params.selection?.end.character).toBe(17)
})
test('keeps file context when there is no active selection', () => {
const params = buildSelectionChangedParams({
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
})
expect(params.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
expect(params.selection).toBeNull()
})
})

View File

@@ -0,0 +1,71 @@
import { EventEmitter } from 'node:events'
import { describe, expect, test } from 'bun:test'
import { ServerWebSocketTransport } from '../src/server/serverWebSocketTransport.js'
class FakeWebSocket extends EventEmitter {
readyState = 1
sent: string[] = []
closed = false
send(data: string, callback?: (error?: Error) => void) {
this.sent.push(data)
callback?.()
}
close() {
this.closed = true
this.emit('close')
}
}
describe('server web socket transport', () => {
test('forwards incoming JSON-RPC messages to the MCP server', async () => {
const socket = new FakeWebSocket()
const transport = new ServerWebSocketTransport(socket)
const messages: unknown[] = []
transport.onmessage = message => {
messages.push(message)
}
await transport.start()
socket.emit(
'message',
Buffer.from(
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'ping',
params: {},
}),
),
)
expect(messages).toHaveLength(1)
expect(messages[0]).toEqual({
jsonrpc: '2.0',
id: 1,
method: 'ping',
params: {},
})
})
test('serializes outgoing JSON-RPC messages back to the websocket', async () => {
const socket = new FakeWebSocket()
const transport = new ServerWebSocketTransport(socket)
await transport.start()
await transport.send({
jsonrpc: '2.0',
id: 2,
result: {},
})
expect(socket.sent).toHaveLength(1)
expect(JSON.parse(socket.sent[0] ?? 'null')).toEqual({
jsonrpc: '2.0',
id: 2,
result: {},
})
})
})

View File

@@ -0,0 +1,48 @@
import { describe, expect, test } from 'bun:test'
import {
clearClaudeCodeIdePort,
setClaudeCodeIdePort,
} from '../src/server/terminalEnvironment.js'
type FakeEnvironmentVariableCollection = {
replaceCalls: Array<{ name: string; value: string }>
deleteCalls: string[]
replace(name: string, value: string): void
delete(name: string): void
}
function createFakeCollection(): FakeEnvironmentVariableCollection {
return {
replaceCalls: [],
deleteCalls: [],
replace(name, value) {
this.replaceCalls.push({ name, value })
},
delete(name) {
this.deleteCalls.push(name)
},
}
}
describe('terminal environment sync', () => {
test('sets CLAUDE_CODE_SSE_PORT to the active bridge port', () => {
const collection = createFakeCollection()
setClaudeCodeIdePort(collection, 52075)
expect(collection.replaceCalls).toEqual([
{
name: 'CLAUDE_CODE_SSE_PORT',
value: '52075',
},
])
})
test('clears CLAUDE_CODE_SSE_PORT when the bridge stops', () => {
const collection = createFakeCollection()
clearClaudeCodeIdePort(collection)
expect(collection.deleteCalls).toEqual(['CLAUDE_CODE_SSE_PORT'])
})
})

View File

@@ -0,0 +1,61 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
type LaunchConfig = {
name?: string
type?: string
request?: string
preLaunchTask?: string
args?: string[]
}
type TaskConfig = {
label?: string
command?: string
args?: string[]
}
const workspaceRoot = join(import.meta.dir, '..', '..', '..')
const launchJsonPath = join(workspaceRoot, '.vscode', 'launch.json')
const tasksJsonPath = join(workspaceRoot, '.vscode', 'tasks.json')
describe('VSCode IDE bridge developer workflow', () => {
test('declares a one-click extension host launch config', () => {
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
configurations?: LaunchConfig[]
}
const config = launchJson.configurations?.find(
item => item.name === 'Run VSCode IDE Bridge',
)
expect(config).toBeDefined()
expect(config?.type).toBe('extensionHost')
expect(config?.request).toBe('launch')
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
expect(config?.args).toContain('--new-window')
expect(config?.args).toContain('--disable-extensions')
expect(config?.args).toContain(
'--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-ide-bridge',
)
})
test('declares a build task for the bridge package', () => {
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
tasks?: TaskConfig[]
}
const task = tasksJson.tasks?.find(
item => item.label === 'Build VSCode IDE Bridge',
)
expect(task).toBeDefined()
expect(task?.command).toBe('bunx')
expect(task?.args).toEqual([
'tsc',
'-p',
'packages/vscode-ide-bridge/tsconfig.json',
])
})
})

View File

@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test'
import {
getActiveSelectionSnapshot,
getWorkspaceFolderPaths,
} from '../src/server/workspaceInfo.js'
describe('workspace info helpers', () => {
test('collects workspace folder fs paths', () => {
expect(
getWorkspaceFolderPaths([
{ uri: { fsPath: 'D:/vibe/claude-code' } },
{ uri: { fsPath: 'D:/vibe/another-project' } },
]),
).toEqual(['D:/vibe/claude-code', 'D:/vibe/another-project'])
})
test('extracts the active editor selection text and file path', () => {
const snapshot = getActiveSelectionSnapshot({
document: {
uri: { fsPath: 'D:/vibe/claude-code/src/cli/print.ts' },
getText(selection: unknown) {
expect(selection).toEqual({
start: { line: 3, character: 1 },
end: { line: 5, character: 0 },
isEmpty: false,
})
return 'selected lines'
},
},
selection: {
start: { line: 3, character: 1 },
end: { line: 5, character: 0 },
isEmpty: false,
},
})
expect(snapshot.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
expect(snapshot.text).toBe('selected lines')
expect(snapshot.selection?.start.line).toBe(3)
})
})

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": [
"bun"
]
},
"include": [
"src/**/*.ts"
]
}