import * as vscode from 'vscode'; import * as os from 'os'; import * as path from 'path'; import { spawn } from 'child_process'; import { type OpenCodeManager } from './opencode'; import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE } from './opencodeConfig'; import { removeProviderAuth } from './opencodeAuth'; import * as gitService from './gitService'; import { getSkillsCatalog, scanSkillsRepository as scanSkillsRepositoryFromGit, installSkillsFromRepository as installSkillsFromGit, type SkillsCatalogSourceConfig, } from './skillsCatalog'; export interface BridgeRequest { id: string; type: string; payload?: unknown; } export interface BridgeResponse { id: string; type: string; success: boolean; data?: unknown; error?: string; } type ApiProxyRequestPayload = { method?: string; path?: string; headers?: Record; bodyBase64?: string; }; type ApiProxyResponsePayload = { status: number; headers: Record; bodyBase64: string; }; interface FileEntry { name: string; path: string; isDirectory: boolean; } interface FileSearchResult { path: string; score?: number; } export interface BridgeContext { manager?: OpenCodeManager; context?: vscode.ExtensionContext; } const SETTINGS_KEY = 'openchamber.settings'; const CLIENT_RELOAD_DELAY_MS = 800; const readSettings = (ctx?: BridgeContext) => { const stored = ctx?.context?.globalState.get>(SETTINGS_KEY) || {}; const restStored = { ...stored }; delete (restStored as Record).lastDirectory; const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; const themeVariant = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light || vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark'; return { themeVariant, lastDirectory: workspaceFolder, ...restStored, }; }; const persistSettings = async (changes: Record, ctx?: BridgeContext) => { const current = readSettings(ctx); const restChanges = { ...(changes || {}) }; delete restChanges.lastDirectory; // Normalize empty-string clears to key removal (match web/desktop behavior) for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId']) { const value = restChanges[key]; if (typeof value === 'string' && value.trim().length === 0) { delete restChanges[key]; } } const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory }; await ctx?.context?.globalState.update(SETTINGS_KEY, merged); return merged; }; const normalizeFsPath = (value: string) => value.replace(/\\/g, '/'); const execGit = async (args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => ( new Promise((resolve) => { const proc = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); let stdout = ''; let stderr = ''; proc.stdout?.on('data', (data) => { stdout += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); }); proc.on('close', (code) => { resolve({ stdout, stderr, exitCode: code ?? 0 }); }); proc.on('error', (error) => { resolve({ stdout: '', stderr: error instanceof Error ? error.message : String(error), exitCode: 1 }); }); }) ); const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise> => { if (names.length === 0) { return new Set(); } const result = await execGit(['check-ignore', '--', ...names], cwd); if (result.exitCode !== 0 || !result.stdout) { return new Set(); } return new Set( result.stdout .split('\n') .map((name: string) => name.trim()) .filter(Boolean) ); }; const expandTildePath = (value: string) => { const trimmed = (value || '').trim(); if (!trimmed) { return trimmed; } if (trimmed === '~') { return os.homedir(); } if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { return path.join(os.homedir(), trimmed.slice(2)); } return trimmed; }; const resolveUserPath = (value: string, baseDirectory: string) => { const expanded = expandTildePath(value); if (!expanded) { return expanded; } if (path.isAbsolute(expanded)) { return expanded; } return path.resolve(baseDirectory, expanded); }; const listDirectoryEntries = async (dirPath: string) => { const uri = vscode.Uri.file(dirPath); const entries = await vscode.workspace.fs.readDirectory(uri); return entries.map(([name, fileType]) => ({ name, path: normalizeFsPath(vscode.Uri.joinPath(uri, name).fsPath), isDirectory: fileType === vscode.FileType.Directory, })); }; const FILE_SEARCH_EXCLUDED_DIRS = new Set([ 'node_modules', '.git', 'dist', 'build', '.next', '.turbo', '.cache', 'coverage', 'tmp', 'logs', ]); const shouldSkipSearchDirectory = (name: string, includeHidden: boolean) => { if (!name) { return false; } if (!includeHidden && name.startsWith('.')) { return true; } return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); }; /** * Fuzzy match scoring function. * Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise. * Higher scores indicate better matches. */ const fuzzyMatchScore = (query: string, candidate: string): number | null => { if (!query) return 0; const q = query.toLowerCase(); const c = candidate.toLowerCase(); // Fast path: exact substring match gets high score if (c.includes(q)) { const idx = c.indexOf(q); let bonus = 0; if (idx === 0) { bonus = 20; } else { const prev = c[idx - 1]; if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { bonus = 15; } } return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); } // Fuzzy match: all query chars must appear in order let score = 0; let lastIndex = -1; let consecutive = 0; for (let i = 0; i < q.length; i++) { const ch = q[i]; if (!ch || ch === ' ') continue; const idx = c.indexOf(ch, lastIndex + 1); if (idx === -1) { return null; // No match } const gap = idx - lastIndex - 1; if (gap === 0) { consecutive++; } else { consecutive = 0; } score += 10; score += Math.max(0, 18 - idx); // Prefer matches near start score -= Math.min(gap, 10); // Penalize gaps // Bonus for word boundary matches if (idx === 0) { score += 12; } else { const prev = c[idx - 1]; if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { score += 10; } } score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches lastIndex = idx; } // Prefer shorter paths score += Math.max(0, 24 - Math.floor(c.length / 3)); return score; }; const searchFilesystemFiles = async ( rootPath: string, query: string, limit: number, includeHidden: boolean, respectGitignore: boolean ) => { const normalizedQuery = (query || '').trim().toLowerCase(); const matchAll = normalizedQuery.length === 0; const rootUri = vscode.Uri.file(rootPath); const queue: vscode.Uri[] = [rootUri]; const visited = new Set([normalizeFsPath(rootUri.fsPath)]); // Collect more candidates for fuzzy matching, then sort and trim const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); const candidates: Array<{ name: string; path: string; relativePath: string; extension?: string; score: number }> = []; const MAX_CONCURRENCY = 5; while (queue.length > 0 && candidates.length < collectLimit) { const batch = queue.splice(0, MAX_CONCURRENCY); const dirLists = await Promise.all( batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][])) ); for (let index = 0; index < batch.length; index += 1) { const currentDir = batch[index]; const dirents = dirLists[index]; const ignoredNames = respectGitignore ? await gitCheckIgnoreNames(normalizeFsPath(currentDir.fsPath), dirents.map(([name]) => name)) : new Set(); for (const [entryName, entryType] of dirents) { if (!entryName || (!includeHidden && entryName.startsWith('.'))) { continue; } if (respectGitignore && ignoredNames.has(entryName)) { continue; } const entryUri = vscode.Uri.joinPath(currentDir, entryName); const absolute = normalizeFsPath(entryUri.fsPath); if (entryType === vscode.FileType.Directory) { if (shouldSkipSearchDirectory(entryName, includeHidden)) { continue; } if (!visited.has(absolute)) { visited.add(absolute); queue.push(entryUri); } continue; } if (entryType !== vscode.FileType.File) { continue; } const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute)); const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; if (matchAll) { candidates.push({ name: entryName, path: absolute, relativePath, extension, score: 0, }); } else { // Try fuzzy match against relative path (includes filename) const score = fuzzyMatchScore(normalizedQuery, relativePath); if (score !== null) { candidates.push({ name: entryName, path: absolute, relativePath, extension, score, }); } } if (candidates.length >= collectLimit) { queue.length = 0; break; } } if (candidates.length >= collectLimit) { break; } } } // Sort by score descending, then by path length, then alphabetically if (!matchAll) { candidates.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; if (a.relativePath.length !== b.relativePath.length) { return a.relativePath.length - b.relativePath.length; } return a.relativePath.localeCompare(b.relativePath); }); } // Return top results without the score field return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ name, path: filePath, relativePath, extension, })); }; const searchDirectory = async ( directory: string, query: string, limit = 60, includeHidden = false, respectGitignore = true ) => { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const rootPath = directory ? resolveUserPath(directory, workspaceRoot) : vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; if (!rootPath) return []; const sanitizedQuery = query?.trim() || ''; if (!sanitizedQuery) { return searchFilesystemFiles(rootPath, '', limit, includeHidden, respectGitignore); } // Fast-path via VS Code's file index (may be case-sensitive depending on platform/workspace). if (!includeHidden) { try { const pattern = `**/*${sanitizedQuery}*`; const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**'; const results = await vscode.workspace.findFiles( new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern), exclude, limit, ); if (Array.isArray(results) && results.length > 0) { return results.map((file) => { const absolute = normalizeFsPath(file.fsPath); const relative = normalizeFsPath(path.relative(rootPath, absolute)); const name = path.basename(absolute); return { name, path: absolute, relativePath: relative || name, extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, }; }); } } catch { // Fall through to filesystem traversal. } } // Fallback: deterministic, case-insensitive traversal with early-exit at limit. return searchFilesystemFiles(rootPath, sanitizedQuery, limit, includeHidden, respectGitignore); }; const fetchModelsMetadata = async () => { const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined; const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined; try { const response = await fetch('https://models.dev/api.json', { signal: controller?.signal, headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error(`models.dev responded with ${response.status}`); } return await response.json(); } finally { if (timeout) { clearTimeout(timeout); } } }; const base64EncodeUtf8 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); const collectHeaders = (headers: Headers): Record => { const result: Record = {}; headers.forEach((value, key) => { result[key] = value; }); return result; }; export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise { const { id, type, payload } = message; try { switch (type) { case 'api:proxy': { const apiUrl = ctx?.manager?.getApiUrl(); if (!apiUrl) { const body = JSON.stringify({ error: 'OpenCode API unavailable' }); const data: ApiProxyResponsePayload = { status: 503, headers: { 'content-type': 'application/json' }, bodyBase64: base64EncodeUtf8(body), }; return { id, type, success: true, data }; } const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload; const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; const normalizedPath = typeof requestPath === 'string' && requestPath.trim().length > 0 ? requestPath.trim().startsWith('/') ? requestPath.trim() : `/${requestPath.trim()}` : '/'; const base = `${apiUrl.replace(/\/+$/, '')}/`; const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); const requestHeaders: Record = { ...(headers || {}) }; // Ensure SSE requests are negotiated correctly. if (normalizedPath === '/event' || normalizedPath === '/global/event') { if (!requestHeaders.Accept) { requestHeaders.Accept = 'text/event-stream'; } requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache'; requestHeaders.Connection = requestHeaders.Connection || 'keep-alive'; } try { const response = await fetch(targetUrl, { method: normalizedMethod, headers: requestHeaders, body: typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD' ? Buffer.from(bodyBase64, 'base64') : undefined, }); const arrayBuffer = await response.arrayBuffer(); const data: ApiProxyResponsePayload = { status: response.status, headers: collectHeaders(response.headers), bodyBase64: Buffer.from(arrayBuffer).toString('base64'), }; return { id, type, success: true, data }; } catch (error) { const body = JSON.stringify({ error: error instanceof Error ? error.message : 'Failed to reach OpenCode API', }); const data: ApiProxyResponsePayload = { status: 502, headers: { 'content-type': 'application/json' }, bodyBase64: base64EncodeUtf8(body), }; return { id, type, success: true, data }; } } case 'files:list': { const { path: dirPath } = payload as { path: string }; const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const resolvedPath = resolveUserPath(dirPath, workspaceRoot); const uri = vscode.Uri.file(resolvedPath); const entries = await vscode.workspace.fs.readDirectory(uri); const result: FileEntry[] = entries.map(([name, fileType]) => ({ name, path: vscode.Uri.joinPath(uri, name).fsPath, isDirectory: fileType === vscode.FileType.Directory, })); return { id, type, success: true, data: { directory: normalizeFsPath(resolvedPath), entries: result } }; } case 'files:search': { const { query, maxResults = 50 } = payload as { query: string; maxResults?: number }; const pattern = `**/*${query}*`; const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', maxResults); const results: FileSearchResult[] = files.map((file) => ({ path: file.fsPath, })); return { id, type, success: true, data: results }; } case 'workspace:folder': { const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; return { id, type, success: true, data: { folder } }; } case 'config:get': { const { key } = payload as { key: string }; const config = vscode.workspace.getConfiguration('openchamber'); const value = config.get(key); return { id, type, success: true, data: { value } }; } case 'api:fs:list': { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const { path: targetPath, respectGitignore } = (payload || {}) as { path?: string; respectGitignore?: boolean }; const target = targetPath || workspaceRoot; const resolvedPath = resolveUserPath(target, workspaceRoot) || workspaceRoot; const entries = await listDirectoryEntries(resolvedPath); const normalized = normalizeFsPath(resolvedPath); if (!respectGitignore) { return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; } const pathsToCheck = entries.map((entry) => entry.name).filter(Boolean); if (pathsToCheck.length === 0) { return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; } try { const result = await execGit(['check-ignore', '--', ...pathsToCheck], normalized); const ignoredNames = new Set( result.stdout .split('\n') .map((name) => name.trim()) .filter(Boolean) ); const filteredEntries = entries.filter((entry) => !ignoredNames.has(entry.name)); return { id, type, success: true, data: { entries: filteredEntries, directory: normalized, path: normalized } }; } catch { return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; } } case 'api:fs:search': { const { directory = '', query = '', limit, includeHidden, respectGitignore } = (payload || {}) as { directory?: string; query?: string; limit?: number; includeHidden?: boolean; respectGitignore?: boolean; }; const files = await searchDirectory(directory, query, limit, Boolean(includeHidden), respectGitignore !== false); return { id, type, success: true, data: { files } }; } case 'api:fs:mkdir': { const target = (payload as { path: string })?.path; if (!target) { return { id, type, success: false, error: 'Path is required' }; } const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const resolvedPath = resolveUserPath(target, workspaceRoot); await vscode.workspace.fs.createDirectory(vscode.Uri.file(resolvedPath)); return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedPath) } }; } case 'api:fs/home': { const workspaceHome = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const home = workspaceHome || os.homedir(); return { id, type, success: true, data: { home: normalizeFsPath(home) } }; } case 'api:fs:read': { const target = (payload as { path: string })?.path; if (!target) { return { id, type, success: false, error: 'Path is required' }; } try { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const resolvedPath = resolveUserPath(target, workspaceRoot); const uri = vscode.Uri.file(resolvedPath); const bytes = await vscode.workspace.fs.readFile(uri); const content = Buffer.from(bytes).toString('utf8'); return { id, type, success: true, data: { content, path: normalizeFsPath(resolvedPath) } }; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to read file'; return { id, type, success: false, error: message }; } } case 'api:fs:write': { const { path: targetPath, content } = (payload as { path: string; content: string }) || {}; if (!targetPath) { return { id, type, success: false, error: 'Path is required' }; } if (typeof content !== 'string') { return { id, type, success: false, error: 'Content is required' }; } try { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const resolvedPath = resolveUserPath(targetPath, workspaceRoot); const uri = vscode.Uri.file(resolvedPath); // Ensure parent directory exists const parentUri = vscode.Uri.file(path.dirname(resolvedPath)); try { await vscode.workspace.fs.createDirectory(parentUri); } catch { // Directory may already exist } await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8')); return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedPath) } }; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to write file'; return { id, type, success: false, error: message }; } } case 'api:fs:delete': { const targetPath = (payload as { path: string })?.path; if (!targetPath) { return { id, type, success: false, error: 'Path is required' }; } try { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const resolvedPath = resolveUserPath(targetPath, workspaceRoot); const uri = vscode.Uri.file(resolvedPath); await vscode.workspace.fs.delete(uri, { recursive: true, useTrash: false }); return { id, type, success: true, data: { success: true } }; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to delete file'; return { id, type, success: false, error: message }; } } case 'api:fs:rename': { const { oldPath, newPath } = (payload as { oldPath: string; newPath: string }) || {}; if (!oldPath) { return { id, type, success: false, error: 'oldPath is required' }; } if (!newPath) { return { id, type, success: false, error: 'newPath is required' }; } try { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const resolvedOld = resolveUserPath(oldPath, workspaceRoot); const resolvedNew = resolveUserPath(newPath, workspaceRoot); const oldUri = vscode.Uri.file(resolvedOld); const newUri = vscode.Uri.file(resolvedNew); await vscode.workspace.fs.rename(oldUri, newUri, { overwrite: false }); return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedNew) } }; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to rename file'; return { id, type, success: false, error: message }; } } case 'api:fs:exec': { const { commands, cwd } = (payload as { commands: string[]; cwd: string }) || {}; if (!Array.isArray(commands) || commands.length === 0) { return { id, type, success: false, error: 'Commands array is required' }; } if (!cwd) { return { id, type, success: false, error: 'Working directory (cwd) is required' }; } try { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const resolvedCwd = resolveUserPath(cwd, workspaceRoot); const { exec } = await import('child_process'); const { promisify } = await import('util'); const execAsync = promisify(exec); const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'); const shellFlag = process.platform === 'win32' ? '/c' : '-c'; const augmentedEnv = { ...process.env, PATH: process.env.PATH, }; const results: Array<{ command: string; success: boolean; exitCode?: number; stdout?: string; stderr?: string; error?: string; }> = []; for (const cmd of commands) { if (typeof cmd !== 'string' || !cmd.trim()) { results.push({ command: cmd, success: false, error: 'Invalid command' }); continue; } try { // Use async exec to not block the extension host event loop const { stdout, stderr } = await execAsync(`${shell} ${shellFlag} "${cmd.replace(/"/g, '\\"')}"`, { cwd: resolvedCwd, env: augmentedEnv, timeout: 300000, // 5 minutes per command }); results.push({ command: cmd, success: true, exitCode: 0, stdout: (stdout || '').trim(), stderr: (stderr || '').trim(), }); } catch (execError) { const err = execError as { code?: number; stdout?: string; stderr?: string; message?: string }; results.push({ command: cmd, success: false, exitCode: typeof err.code === 'number' ? err.code : 1, stdout: (err.stdout || '').trim(), stderr: (err.stderr || '').trim(), error: err.message, }); } } const allSucceeded = results.every((r) => r.success); return { id, type, success: true, data: { success: allSucceeded, results } }; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to execute commands'; return { id, type, success: false, error: message }; } } case 'api:files/pick': { const MAX_SIZE = 10 * 1024 * 1024; const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false; const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri; const picks = await vscode.window.showOpenDialog({ canSelectFiles: true, canSelectFolders: false, canSelectMany: allowMany, defaultUri, openLabel: 'Attach', }); if (!picks || picks.length === 0) { return { id, type, success: true, data: { files: [], skipped: [] } }; } const files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = []; const skipped: Array<{ name: string; reason: string }> = []; const guessMime = (ext: string) => { switch (ext) { case '.png': case '.jpg': case '.jpeg': case '.gif': case '.bmp': case '.webp': return `image/${ext.replace('.', '')}`; case '.pdf': return 'application/pdf'; case '.txt': case '.log': return 'text/plain'; case '.json': return 'application/json'; case '.md': case '.markdown': return 'text/markdown'; default: return 'application/octet-stream'; } }; for (const uri of picks) { try { const stat = await vscode.workspace.fs.stat(uri); const size = stat.size ?? 0; const name = path.basename(uri.fsPath); if (size > MAX_SIZE) { skipped.push({ name, reason: 'File exceeds 10MB limit' }); continue; } const bytes = await vscode.workspace.fs.readFile(uri); const ext = path.extname(name).toLowerCase(); const mimeType = guessMime(ext); const base64 = Buffer.from(bytes).toString('base64'); const dataUrl = `data:${mimeType};base64,${base64}`; files.push({ name, mimeType, size, dataUrl }); } catch (error) { const name = path.basename(uri.fsPath); skipped.push({ name, reason: error instanceof Error ? error.message : 'Failed to read file' }); } } return { id, type, success: true, data: { files, skipped } }; } case 'api:config/settings:get': { const settings = readSettings(ctx); return { id, type, success: true, data: settings }; } case 'api:config/settings:save': { const changes = (payload as Record) || {}; const updated = await persistSettings(changes, ctx); return { id, type, success: true, data: updated }; } case 'api:config/reload': { await ctx?.manager?.restart(); return { id, type, success: true, data: { restarted: true } }; } case 'api:config/agents': { const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record; directory?: string }; const agentName = typeof name === 'string' ? name.trim() : ''; if (!agentName) { return { id, type, success: false, error: 'Agent name is required' }; } // Use directory from request if provided, otherwise fall back to workspace const workingDirectory = (typeof directory === 'string' && directory.trim()) ? directory.trim() : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; if (normalizedMethod === 'GET') { const sources = getAgentSources(agentName, workingDirectory); const scope = sources.md.exists ? sources.md.scope : (sources.json.exists ? sources.json.scope : null); return { id, type, success: true, data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, }; } if (normalizedMethod === 'POST') { // Extract scope from body if present const scopeValue = body?.scope as string | undefined; const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined; createAgent(agentName, (body || {}) as Record, workingDirectory, scope); await ctx?.manager?.restart(); return { id, type, success: true, data: { success: true, requiresReload: true, message: `Agent ${agentName} created successfully. Reloading interface…`, reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }, }; } if (normalizedMethod === 'PATCH') { updateAgent(agentName, (body || {}) as Record, workingDirectory); await ctx?.manager?.restart(); return { id, type, success: true, data: { success: true, requiresReload: true, message: `Agent ${agentName} updated successfully. Reloading interface…`, reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }, }; } if (normalizedMethod === 'DELETE') { deleteAgent(agentName, workingDirectory); await ctx?.manager?.restart(); return { id, type, success: true, data: { success: true, requiresReload: true, message: `Agent ${agentName} deleted successfully. Reloading interface…`, reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }, }; } return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } case 'api:config/commands': { const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record; directory?: string }; const commandName = typeof name === 'string' ? name.trim() : ''; if (!commandName) { return { id, type, success: false, error: 'Command name is required' }; } // Use directory from request if provided, otherwise fall back to workspace const workingDirectory = (typeof directory === 'string' && directory.trim()) ? directory.trim() : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; if (normalizedMethod === 'GET') { const sources = getCommandSources(commandName, workingDirectory); const scope = sources.md.exists ? sources.md.scope : (sources.json.exists ? sources.json.scope : null); return { id, type, success: true, data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, }; } if (normalizedMethod === 'POST') { // Extract scope from body if present const scopeValue = body?.scope as string | undefined; const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined; createCommand(commandName, (body || {}) as Record, workingDirectory, scope); await ctx?.manager?.restart(); return { id, type, success: true, data: { success: true, requiresReload: true, message: `Command ${commandName} created successfully. Reloading interface…`, reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }, }; } if (normalizedMethod === 'PATCH') { updateCommand(commandName, (body || {}) as Record, workingDirectory); await ctx?.manager?.restart(); return { id, type, success: true, data: { success: true, requiresReload: true, message: `Command ${commandName} updated successfully. Reloading interface…`, reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }, }; } if (normalizedMethod === 'DELETE') { deleteCommand(commandName, workingDirectory); await ctx?.manager?.restart(); return { id, type, success: true, data: { success: true, requiresReload: true, message: `Command ${commandName} deleted successfully. Reloading interface…`, reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }, }; } return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } case 'api:config/skills': { const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record }; const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; // LIST all skills (no name provided) if (!name && normalizedMethod === 'GET') { const skills = discoverSkills(workingDirectory); return { id, type, success: true, data: { skills } }; } const skillName = typeof name === 'string' ? name.trim() : ''; if (!skillName) { return { id, type, success: false, error: 'Skill name is required' }; } if (normalizedMethod === 'GET') { const sources = getSkillSources(skillName, workingDirectory); return { id, type, success: true, data: { name: skillName, sources, scope: sources.md.scope, source: sources.md.source }, }; } if (normalizedMethod === 'POST') { const scopeValue = body?.scope as string | undefined; const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined; createSkill(skillName, (body || {}) as Record, workingDirectory, scope); // Skills are just files - OpenCode loads them on-demand, no restart needed return { id, type, success: true, data: { success: true, requiresReload: false, message: `Skill ${skillName} created successfully`, }, }; } if (normalizedMethod === 'PATCH') { updateSkill(skillName, (body || {}) as Record, workingDirectory); // Skills are just files - OpenCode loads them on-demand, no restart needed return { id, type, success: true, data: { success: true, requiresReload: false, message: `Skill ${skillName} updated successfully`, }, }; } if (normalizedMethod === 'DELETE') { deleteSkill(skillName, workingDirectory); // Skills are just files - OpenCode loads them on-demand, no restart needed return { id, type, success: true, data: { success: true, requiresReload: false, message: `Skill ${skillName} deleted successfully`, }, }; } return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } case 'api:config/skills:catalog': { const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh); const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const settings = readSettings(ctx); const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs; const additionalSources: SkillsCatalogSourceConfig[] = Array.isArray(rawCatalogs) ? (rawCatalogs .map((entry) => { if (!entry || typeof entry !== 'object') return null; const candidate = entry as Record; const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; const source = typeof candidate.source === 'string' ? candidate.source.trim() : ''; const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : ''; if (!id || !label || !source) return null; const normalized: SkillsCatalogSourceConfig = { id, label, description: source, source, ...(subpath ? { defaultSubpath: subpath } : {}), }; return normalized; }) .filter((v) => v !== null) as SkillsCatalogSourceConfig[]) : []; const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources); return { id, type, success: true, data }; } case 'api:config/skills:scan': { const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string }; const data = await scanSkillsRepositoryFromGit({ source: String(body.source || ''), subpath: body.subpath, }); return { id, type, success: true, data }; } case 'api:config/skills:install': { const body = (payload || {}) as { source?: string; subpath?: string; scope?: 'user' | 'project'; selections?: Array<{ skillDir: string }>; conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; conflictDecisions?: Record; }; const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const data = await installSkillsFromGit({ source: String(body.source || ''), subpath: body.subpath, scope: body.scope === 'project' ? 'project' : 'user', workingDirectory: body.scope === 'project' ? workingDirectory : undefined, selections: Array.isArray(body.selections) ? body.selections : [], conflictPolicy: body.conflictPolicy, conflictDecisions: body.conflictDecisions, }); return { id, type, success: true, data }; } case 'api:config/skills/files': { const { method, name, filePath, content } = (payload || {}) as { method?: string; name?: string; filePath?: string; content?: string; }; const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const skillName = typeof name === 'string' ? name.trim() : ''; if (!skillName) { return { id, type, success: false, error: 'Skill name is required' }; } const relativePath = typeof filePath === 'string' ? filePath.trim() : ''; if (!relativePath) { return { id, type, success: false, error: 'File path is required' }; } const sources = getSkillSources(skillName, workingDirectory); if (!sources.md.dir) { return { id, type, success: false, error: `Skill "${skillName}" not found` }; } const skillDir = sources.md.dir; const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; if (normalizedMethod === 'GET') { const fileContent = readSkillSupportingFile(skillDir, relativePath); if (fileContent === null) { return { id, type, success: false, error: `File "${relativePath}" not found in skill "${skillName}"` }; } return { id, type, success: true, data: { content: fileContent } }; } if (normalizedMethod === 'PUT') { writeSkillSupportingFile(skillDir, relativePath, content || ''); return { id, type, success: true, data: { success: true } }; } if (normalizedMethod === 'DELETE') { deleteSkillSupportingFile(skillDir, relativePath); return { id, type, success: true, data: { success: true } }; } return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } case 'api:opencode/directory': { const target = (payload as { path?: string })?.path; if (!target) { return { id, type, success: false, error: 'Path is required' }; } const baseDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const resolvedPath = resolveUserPath(target, baseDirectory); const result = await ctx?.manager?.setWorkingDirectory(resolvedPath); if (!result) { return { id, type, success: false, error: 'OpenCode manager unavailable' }; } return { id, type, success: true, data: result }; } case 'api:models/metadata': { try { const data = await fetchModelsMetadata(); return { id, type, success: true, data }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { id, type, success: false, error: errorMessage }; } } case 'editor:openFile': { const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number }; try { const doc = await vscode.workspace.openTextDocument(filePath); const options: vscode.TextDocumentShowOptions = {}; if (typeof line === 'number') { const pos = new vscode.Position(Math.max(0, line - 1), column || 0); options.selection = new vscode.Range(pos, pos); } await vscode.window.showTextDocument(doc, options); return { id, type, success: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { id, type, success: false, error: errorMessage }; } } case 'editor:openDiff': { const { original, modified, label } = payload as { original: string; modified: string; label?: string }; try { // If the paths are just content, we need to create virtual documents or temp files. // However, 'editor:openDiff' usually implies comparing two URIs. // If the payload contains file paths: const originalUri = vscode.Uri.file(original); const modifiedUri = vscode.Uri.file(modified); const title = label || `${path.basename(original)} ↔ ${path.basename(modified)}`; await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title); return { id, type, success: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { id, type, success: false, error: errorMessage }; } } case 'api:provider/auth:delete': { const { providerId } = (payload || {}) as { providerId?: string }; if (!providerId) { return { id, type, success: false, error: 'Provider ID is required' }; } try { const removed = removeProviderAuth(providerId); if (removed) { await ctx?.manager?.restart(); } return { id, type, success: true, data: { success: true, removed, requiresReload: removed, message: removed ? `Provider ${providerId} disconnected successfully. Reloading interface…` : `Provider ${providerId} was not configured.`, reloadDelayMs: removed ? CLIENT_RELOAD_DELAY_MS : undefined, }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { id, type, success: false, error: errorMessage }; } } case 'vscode:command': { const { command, args } = (payload || {}) as { command?: string; args?: unknown[] }; if (!command) { return { id, type, success: false, error: 'Command is required' }; } try { const result = await vscode.commands.executeCommand(command, ...(args || [])); return { id, type, success: true, data: { result } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { id, type, success: false, error: errorMessage }; } } // ============== Git Operations ============== case 'api:git/check': { const { directory } = (payload || {}) as { directory?: string }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } const isRepo = await gitService.checkIsGitRepository(directory); return { id, type, success: true, data: isRepo }; } case 'api:git/worktree-type': { const { directory } = (payload || {}) as { directory?: string }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } const isLinked = await gitService.isLinkedWorktree(directory); return { id, type, success: true, data: isLinked }; } case 'api:git/status': { const { directory } = (payload || {}) as { directory?: string }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } const status = await gitService.getGitStatus(directory); return { id, type, success: true, data: status }; } case 'api:git/branches': { const { directory, method, name, startPoint, force } = (payload || {}) as { directory?: string; method?: string; name?: string; startPoint?: string; force?: boolean; }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; if (normalizedMethod === 'GET') { const branches = await gitService.getGitBranches(directory); return { id, type, success: true, data: branches }; } if (normalizedMethod === 'POST') { if (!name) { return { id, type, success: false, error: 'Branch name is required' }; } const result = await gitService.createBranch(directory, name, startPoint); return { id, type, success: true, data: result }; } if (normalizedMethod === 'DELETE') { if (!name) { return { id, type, success: false, error: 'Branch name is required' }; } const result = await gitService.deleteGitBranch(directory, name, force); return { id, type, success: true, data: result }; } return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } case 'api:git/remote-branches': { const { directory, branch, remote } = (payload || {}) as { directory?: string; branch?: string; remote?: string; }; if (!directory || !branch) { return { id, type, success: false, error: 'Directory and branch are required' }; } const result = await gitService.deleteRemoteBranch(directory, branch, remote); return { id, type, success: true, data: result }; } case 'api:git/checkout': { const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; if (!directory || !branch) { return { id, type, success: false, error: 'Directory and branch are required' }; } const result = await gitService.checkoutBranch(directory, branch); return { id, type, success: true, data: result }; } case 'api:git/worktrees': { const { directory, method, path: worktreePath, branch, createBranch, force } = (payload || {}) as { directory?: string; method?: string; path?: string; branch?: string; createBranch?: boolean; force?: boolean; }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; if (normalizedMethod === 'GET') { const worktrees = await gitService.listGitWorktrees(directory); return { id, type, success: true, data: worktrees }; } if (normalizedMethod === 'POST') { if (!worktreePath || !branch) { return { id, type, success: false, error: 'Path and branch are required' }; } const result = await gitService.addGitWorktree(directory, worktreePath, branch, createBranch); return { id, type, success: true, data: result }; } if (normalizedMethod === 'DELETE') { if (!worktreePath) { return { id, type, success: false, error: 'Path is required' }; } const result = await gitService.removeGitWorktree(directory, worktreePath, force); return { id, type, success: true, data: result }; } return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } case 'api:git/diff': { const { directory, path: filePath, staged, contextLines } = (payload || {}) as { directory?: string; path?: string; staged?: boolean; contextLines?: number; }; if (!directory || !filePath) { return { id, type, success: false, error: 'Directory and path are required' }; } const result = await gitService.getGitDiff(directory, filePath, staged, contextLines); return { id, type, success: true, data: result }; } case 'api:git/file-diff': { const { directory, path: filePath, staged } = (payload || {}) as { directory?: string; path?: string; staged?: boolean; }; if (!directory || !filePath) { return { id, type, success: false, error: 'Directory and path are required' }; } const result = await gitService.getGitFileDiff(directory, filePath, staged); return { id, type, success: true, data: result }; } case 'api:git/revert': { const { directory, path: filePath } = (payload || {}) as { directory?: string; path?: string }; if (!directory || !filePath) { return { id, type, success: false, error: 'Directory and path are required' }; } await gitService.revertGitFile(directory, filePath); return { id, type, success: true, data: { success: true } }; } case 'api:git/commit': { const { directory, message, addAll, files } = (payload || {}) as { directory?: string; message?: string; addAll?: boolean; files?: string[]; }; if (!directory || !message) { return { id, type, success: false, error: 'Directory and message are required' }; } const result = await gitService.createGitCommit(directory, message, { addAll, files }); return { id, type, success: true, data: result }; } case 'api:git/push': { const { directory, remote, branch, options } = (payload || {}) as { directory?: string; remote?: string; branch?: string; options?: string[] | Record; }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } const result = await gitService.gitPush(directory, { remote, branch, options }); return { id, type, success: true, data: result }; } case 'api:git/pull': { const { directory, remote, branch } = (payload || {}) as { directory?: string; remote?: string; branch?: string; }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } const result = await gitService.gitPull(directory, { remote, branch }); return { id, type, success: true, data: result }; } case 'api:git/fetch': { const { directory, remote, branch } = (payload || {}) as { directory?: string; remote?: string; branch?: string; }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } const result = await gitService.gitFetch(directory, { remote, branch }); return { id, type, success: true, data: result }; } case 'api:git/log': { const { directory, maxCount, from, to, file } = (payload || {}) as { directory?: string; maxCount?: number; from?: string; to?: string; file?: string; }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } const result = await gitService.getGitLog(directory, { maxCount, from, to, file }); return { id, type, success: true, data: result }; } case 'api:git/commit-files': { const { directory, hash } = (payload || {}) as { directory?: string; hash?: string }; if (!directory || !hash) { return { id, type, success: false, error: 'Directory and hash are required' }; } const result = await gitService.getCommitFiles(directory, hash); return { id, type, success: true, data: result }; } case 'api:git/identity': { const { directory, method, userName, userEmail, sshKey } = (payload || {}) as { directory?: string; method?: string; userName?: string; userEmail?: string; sshKey?: string | null; }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; if (normalizedMethod === 'GET') { const identity = await gitService.getCurrentGitIdentity(directory); return { id, type, success: true, data: identity }; } if (normalizedMethod === 'POST') { if (!userName || !userEmail) { return { id, type, success: false, error: 'userName and userEmail are required' }; } const result = await gitService.setGitIdentity(directory, userName, userEmail, sshKey); return { id, type, success: true, data: result }; } return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } case 'api:git/ignore-openchamber': { const { directory } = (payload || {}) as { directory?: string }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } await gitService.ensureOpenChamberIgnored(directory); return { id, type, success: true, data: { success: true } }; } default: return { id, type, success: false, error: `Unknown message type: ${type}` }; } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); return { id, type, success: false, error: errorMessage }; } }