From e7b62d8fc01988b3966092d38cc79fee4f7736b3 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:38:41 +0000 Subject: [PATCH] feat: introduce provider abstraction with local Claude CLI support Adds a provider abstraction layer that lets the app run either via the existing E2B cloud sandbox (default) or via a locally-installed Claude CLI subprocess, skipping sandboxing entirely. New files in apps/web/lib/providers/: - types.ts: AIProvider interface and types - local-cli-provider.ts: spawns claude -p with stream-json - sandboxed-provider.ts: typed wrapper for existing E2B approach - factory.ts: createProvider()/isLocalCLIMode() based on CLAUDE_PROVIDER env - local-cli-handler.ts: handleLocalCLIGeneration() - skips sandbox entirely - index.ts: re-exports Modified: - apps/web/lib/claude-code-handler.ts: routes to local handler when CLAUDE_PROVIDER=local-cli - .env.example: documents CLAUDE_PROVIDER and LOCAL_PROJECTS_PATH Closes #1 Co-authored-by: Rodrigo Figueroa --- .env.example | 25 + apps/web/lib/claude-code-handler.ts | 15 +- apps/web/lib/providers/factory.ts | 56 +++ apps/web/lib/providers/index.ts | 17 + apps/web/lib/providers/local-cli-handler.ts | 156 ++++++ apps/web/lib/providers/local-cli-provider.ts | 477 +++++++++++++++++++ apps/web/lib/providers/sandboxed-provider.ts | 53 +++ apps/web/lib/providers/types.ts | 115 +++++ 8 files changed, 912 insertions(+), 2 deletions(-) create mode 100644 apps/web/lib/providers/factory.ts create mode 100644 apps/web/lib/providers/index.ts create mode 100644 apps/web/lib/providers/local-cli-handler.ts create mode 100644 apps/web/lib/providers/local-cli-provider.ts create mode 100644 apps/web/lib/providers/sandboxed-provider.ts create mode 100644 apps/web/lib/providers/types.ts diff --git a/.env.example b/.env.example index 949a1b22..f6247356 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,31 @@ SANDBOX_PROVIDER= DAYTONA_API_KEY= DAYTONA_API_URL= +# --------------------------------------------------------------------------- +# AI Provider Mode +# --------------------------------------------------------------------------- +# Controls how Claude code generation runs. Two modes are available: +# +# sandboxed (default) — Runs inside an E2B cloud sandbox. +# Requires ANTHROPIC_API_KEY and E2B_API_KEY. +# Best for cloud/production deployments. +# +# local-cli — Spawns the locally-installed `claude` CLI as a +# subprocess using `claude -p "..." --output-format stream-json`. +# No E2B sandbox or ANTHROPIC_API_KEY required. +# Requires the Claude CLI to be installed and authenticated +# (`claude login`). Best for local development. +# +# When CLAUDE_PROVIDER=local-cli, projects live at LOCAL_PROJECTS_PATH/ +# and the app dev server is expected at http://localhost:8081. +# +CLAUDE_PROVIDER=sandboxed + +# Root directory for local-cli project working directories. +# Each project gets its own subdirectory: $LOCAL_PROJECTS_PATH/ +# Defaults to ~/local-projects when not set. +LOCAL_PROJECTS_PATH= + ### Optional env vars # Domain of the site diff --git a/apps/web/lib/claude-code-handler.ts b/apps/web/lib/claude-code-handler.ts index 1cfbb10f..44c91a64 100644 --- a/apps/web/lib/claude-code-handler.ts +++ b/apps/web/lib/claude-code-handler.ts @@ -6,6 +6,8 @@ import { Sandbox } from '@e2b/code-interpreter' import { eq, and } from 'drizzle-orm' import { getSkillTemplate, getSkillFilePath } from '@/lib/skills/templates' import { validateSkillIds } from '@/lib/skills' +import { isLocalCLIMode } from '@/lib/providers/factory' +import { handleLocalCLIGeneration } from '@/lib/providers/local-cli-handler' export interface ClaudeCodeHandlerRequest { userMessage: string @@ -31,13 +33,22 @@ export interface ClaudeCodeStreamCallbacks { } /** - * Handles Claude Code generation by directly invoking the service - * This avoids intermediate fetch() calls and their timeout issues + * Handles Claude Code generation by directly invoking the service. + * This avoids intermediate fetch() calls and their timeout issues. + * + * Routes to one of two implementations based on CLAUDE_PROVIDER env var: + * - "sandboxed" (default): E2B cloud sandbox + Claude Agent SDK + * - "local-cli": locally-installed claude CLI subprocess, no sandbox */ export async function handleClaudeCodeGeneration( request: ClaudeCodeHandlerRequest, callbacks: ClaudeCodeStreamCallbacks ): Promise { + // Route to the local-cli handler when CLAUDE_PROVIDER=local-cli + if (isLocalCLIMode()) { + console.log('[Claude Code Handler] Routing to local-cli provider (CLAUDE_PROVIDER=local-cli)') + return handleLocalCLIGeneration(request, callbacks) + } console.log('[Claude Code Handler] Called with:', { projectId: request.projectId, userID: request.userID, diff --git a/apps/web/lib/providers/factory.ts b/apps/web/lib/providers/factory.ts new file mode 100644 index 00000000..badc93bc --- /dev/null +++ b/apps/web/lib/providers/factory.ts @@ -0,0 +1,56 @@ +/** + * Provider Factory + * + * Returns the correct AIProvider implementation based on the CLAUDE_PROVIDER + * environment variable: + * + * CLAUDE_PROVIDER=sandboxed (default) → SandboxedClaudeProvider + * CLAUDE_PROVIDER=local-cli → LocalCLIProvider + * + * Usage: + * import { createProvider, getProviderMode } from '@/lib/providers/factory' + * const provider = createProvider() + * await provider.generate(request, callbacks) + */ + +import type { AIProvider, ProviderMode } from './types' + +export function getProviderMode(): ProviderMode { + const raw = process.env.CLAUDE_PROVIDER ?? 'sandboxed' + if (raw === 'local-cli' || raw === 'sandboxed') { + return raw + } + console.warn( + `[ProviderFactory] Unknown CLAUDE_PROVIDER value "${raw}", falling back to "sandboxed"`, + ) + return 'sandboxed' +} + +export function isLocalCLIMode(): boolean { + return getProviderMode() === 'local-cli' +} + +/** + * Creates and returns the active AI provider. + * + * Providers are stateless — a new instance is safe to create per request. + */ +export function createProvider(): AIProvider { + const mode = getProviderMode() + + switch (mode) { + case 'local-cli': { + // Lazy import to avoid pulling child_process / fs into sandboxed builds + const { LocalCLIProvider } = require('./local-cli-provider') as typeof import('./local-cli-provider') + return new LocalCLIProvider() + } + + case 'sandboxed': + default: { + // The sandboxed provider wraps ClaudeCodeService + E2B. + // It is the default and production mode. + const { SandboxedClaudeProvider } = require('./sandboxed-provider') as typeof import('./sandboxed-provider') + return new SandboxedClaudeProvider() + } + } +} diff --git a/apps/web/lib/providers/index.ts b/apps/web/lib/providers/index.ts new file mode 100644 index 00000000..60d9307a --- /dev/null +++ b/apps/web/lib/providers/index.ts @@ -0,0 +1,17 @@ +/** + * Provider Abstraction — public exports + * + * Import types and utilities from here rather than from the individual files. + */ + +export type { + AIProvider, + ProviderCallbacks, + ProviderCompletionResult, + ProviderGenerationRequest, + ProviderMode, +} from './types' + +export { createProvider, getProviderMode, isLocalCLIMode } from './factory' +export { LocalCLIProvider } from './local-cli-provider' +export { SandboxedClaudeProvider } from './sandboxed-provider' diff --git a/apps/web/lib/providers/local-cli-handler.ts b/apps/web/lib/providers/local-cli-handler.ts new file mode 100644 index 00000000..43745418 --- /dev/null +++ b/apps/web/lib/providers/local-cli-handler.ts @@ -0,0 +1,156 @@ +/** + * Local CLI Handler + * + * Handles Claude Code generation when CLAUDE_PROVIDER=local-cli. + * + * This is the local-mode counterpart of claude-code-handler.ts. + * It skips all E2B sandbox logic and instead: + * 1. Loads the project record from the database (for session ID resumption). + * 2. Resolves the project's local working directory. + * 3. Delegates to LocalCLIProvider, which spawns `claude -p ...` as a subprocess. + * 4. On completion, persists the new session ID back to the database. + * + * ## What is intentionally NOT done in local-cli mode + * + * - No sandbox creation / connection (skips E2B entirely). + * - No skill file writing via sandbox.files.write() — skills are written to the + * local filesystem directly by LocalCLIProvider.writeSkillFiles(). + * - No static bundle build (bundle building is sandbox-specific). + * - No GitHub commit triggering (add separately if needed). + * - No usage tracking by token count (tokens aren't exposed by the CLI). + * Code-generation tracking is still done so the usage dashboard remains useful. + */ + +import { db } from '@/lib/db' +import { projects } from '@react-native-vibe-code/database' +import { eq, and } from 'drizzle-orm' +import { UsageTracker } from '@/lib/usage-tracking' +import { LocalCLIProvider } from './local-cli-provider' +import type { ClaudeCodeHandlerRequest, ClaudeCodeStreamCallbacks } from '../claude-code-handler' +import type { ProviderGenerationRequest } from './types' + +const provider = new LocalCLIProvider() + +/** + * Entry point for local-cli mode — mirrors the signature of + * handleClaudeCodeGeneration() so the chat route can call either transparently. + */ +export async function handleLocalCLIGeneration( + request: ClaudeCodeHandlerRequest, + callbacks: ClaudeCodeStreamCallbacks, +): Promise { + console.log('[LocalCLI Handler] Starting generation:', { + projectId: request.projectId, + userID: request.userID, + messageLength: request.userMessage.length, + messageId: request.messageId, + }) + + if (!request.userID) { + callbacks.onError('User ID is required') + return + } + + if (!request.projectId) { + callbacks.onError('Project ID is required') + return + } + + // Check that the CLI is actually available before proceeding + const available = await provider.isAvailable() + if (!available) { + callbacks.onError( + 'Claude CLI not found or not installed. ' + + 'Install it from https://claude.ai/download and run "claude login" to authenticate. ' + + 'To use cloud sandboxes instead, set CLAUDE_PROVIDER=sandboxed.', + ) + return + } + + // Load project record from database to get the session ID for multi-turn resumption + let project: any = null + try { + const rows = await db + .select() + .from(projects) + .where( + and( + eq(projects.id, request.projectId), + eq(projects.userId, request.userID), + ), + ) + .limit(1) + project = rows[0] ?? null + } catch (err) { + console.error('[LocalCLI Handler] Failed to load project:', err) + // Non-fatal — proceed without session resumption + } + + if (!project) { + console.warn('[LocalCLI Handler] Project not found in database — starting fresh conversation') + } + + const generationRequest: ProviderGenerationRequest = { + userMessage: request.userMessage, + messageId: request.messageId, + projectId: request.projectId, + userId: request.userID, + isFirstMessage: request.isFirstMessage, + images: request.images, + imageAttachments: request.imageAttachments, + fileEdition: request.fileEdition, + selectionData: request.selectionData, + sessionId: request.conversationId || project?.conversationId || undefined, + claudeModel: request.claudeModel, + skills: request.skills, + } + + await provider.generate(generationRequest, { + onMessage: callbacks.onMessage, + + onComplete: async (result) => { + // Track code generation for billing dashboard (no token data in local mode) + try { + await UsageTracker.trackCodeGeneration( + request.userID, + request.projectId, + result.filesModified?.length ?? 0, + 0, // token count unknown in local-cli mode + ) + } catch (err) { + console.error('[LocalCLI Handler] Failed to track usage:', err) + } + + // Persist the new session ID so the next turn can resume the conversation + if (result.conversationId && project) { + try { + console.log('[LocalCLI Handler] Saving session ID:', result.conversationId) + await db + .update(projects) + .set({ conversationId: result.conversationId, updatedAt: new Date() }) + .where(eq(projects.id, request.projectId)) + } catch (err) { + console.error('[LocalCLI Handler] Failed to save session ID:', err) + } + } + + callbacks.onComplete({ + success: result.success, + type: 'completion', + projectId: request.projectId, + projectTitle: project?.title ?? 'Local Project', + // In local mode there's no sandbox — the user runs the app locally + url: result.previewUrl ?? 'http://localhost:8081', + summary: result.summary, + filesModified: result.filesModified, + conversationId: result.conversationId, + provider: 'local-cli', + }) + }, + + onError: (error) => { + console.error('[LocalCLI Handler] Provider error:', error) + callbacks.onError(error) + }, + }) +} diff --git a/apps/web/lib/providers/local-cli-provider.ts b/apps/web/lib/providers/local-cli-provider.ts new file mode 100644 index 00000000..dd34910f --- /dev/null +++ b/apps/web/lib/providers/local-cli-provider.ts @@ -0,0 +1,477 @@ +/** + * LocalCLIProvider + * + * Runs AI code generation by spawning the locally-installed `claude` CLI as a + * subprocess, skipping the E2B cloud sandbox entirely. + * + * ## Transport + * + * Uses `claude -p "" --output-format stream-json [options]` which: + * 1. Sends the prompt non-interactively via the `-p` / `--print` flag. + * 2. Streams responses as newline-delimited JSON on stdout + * (one JSON object per line, format: `--output-format stream-json`). + * 3. Exits with code 0 on success, non-zero on failure. + * + * Each stdout line is one of: + * - `{"type":"system","subtype":"init","session_id":"...","tools":[...],...}` + * - `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"..."},...],...}}` + * - `{"type":"result","subtype":"success","cost_usd":0.0,"is_error":false,...,"result":"..."}` + * - `{"type":"result","subtype":"error_during_execution",...,"is_error":true}` + * + * ## Authentication + * + * Uses the user's local `~/.claude/` session. No ANTHROPIC_API_KEY required. + * The user must have run `claude login` beforehand. + * + * ## Working Directory + * + * By default each project lives at `{LOCAL_PROJECTS_PATH}/{projectId}`. + * The directory is created automatically on first use. + * + * ## Multi-turn Conversations + * + * The CLI flag `--resume ` resumes a previous conversation. + * The session ID is captured from the `system/init` message and persisted + * in the projects table (same field used by the sandboxed provider). + */ + +import { spawn } from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import type { + AIProvider, + ProviderCallbacks, + ProviderCompletionResult, + ProviderGenerationRequest, + ProviderMode, +} from './types' + +// --------------------------------------------------------------------------- +// Internal message types from `claude --output-format stream-json` +// --------------------------------------------------------------------------- + +interface CLISystemMessage { + type: 'system' + subtype: 'init' + session_id: string + tools: string[] + mcp_servers: unknown[] +} + +interface CLIAssistantMessage { + type: 'assistant' + message: { + id: string + type: 'message' + role: 'assistant' + content: Array< + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: unknown } + > + model: string + stop_reason: string | null + stop_sequence: string | null + usage: { input_tokens: number; output_tokens: number } + } +} + +interface CLIResultMessage { + type: 'result' + subtype: 'success' | 'error_during_execution' | string + cost_usd: number + is_error: boolean + duration_ms: number + duration_api_ms: number + session_id: string + result: string + total_cost?: number + num_turns?: number +} + +type CLIStreamMessage = CLISystemMessage | CLIAssistantMessage | CLIResultMessage + +// --------------------------------------------------------------------------- +// LocalCLIProvider +// --------------------------------------------------------------------------- + +export class LocalCLIProvider implements AIProvider { + readonly name = 'local-cli' + readonly mode: ProviderMode = 'local-cli' + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + async generate( + request: ProviderGenerationRequest, + callbacks: ProviderCallbacks, + ): Promise { + const projectDir = this.resolveProjectDir(request) + + // Ensure the project directory exists before launching the CLI + try { + fs.mkdirSync(projectDir, { recursive: true }) + } catch (err) { + callbacks.onError( + `Failed to create project directory ${projectDir}: ${err instanceof Error ? err.message : String(err)}`, + ) + return + } + + // Write system prompt to a temp file inside the project dir so we can + // pass it via --system-prompt-file (avoids shell escaping headaches with + // long prompts containing quotes/backticks). + let systemPromptFilePath: string | undefined + try { + const { getPromptWithCloudStatus } = await import('@react-native-vibe-code/prompt-engine') + const systemPrompt = getPromptWithCloudStatus(false) + systemPromptFilePath = path.join(projectDir, '.claude-system-prompt.txt') + fs.writeFileSync(systemPromptFilePath, systemPrompt, 'utf8') + } catch (err) { + console.warn('[LocalCLIProvider] Could not write system prompt file:', err) + // Non-fatal — proceed without a system prompt file + } + + // Write skill SKILL.md files into the project's .claude/skills/ directory + if (request.skills && request.skills.length > 0) { + await this.writeSkillFiles(request.skills, projectDir) + } + + const args = this.buildArgs(request, systemPromptFilePath) + + console.log('[LocalCLIProvider] Spawning claude CLI:', { + cwd: projectDir, + args: args.map((a, i) => (i === 1 ? a.substring(0, 80) + '...' : a)), // truncate prompt in logs + sessionId: request.sessionId || 'new session', + }) + + return new Promise((resolve) => { + let capturedSessionId: string | null = null + let lineBuffer = '' + let completionResult: ProviderCompletionResult | null = null + let hasResolved = false + + const done = (result: ProviderCompletionResult | null, error?: string) => { + if (hasResolved) return + hasResolved = true + if (systemPromptFilePath) { + try { + fs.unlinkSync(systemPromptFilePath!) + } catch { + // Best-effort cleanup + } + } + if (error) { + callbacks.onError(error) + } else { + callbacks.onComplete( + result ?? { + success: true, + conversationId: capturedSessionId || undefined, + summary: 'Task completed via local Claude CLI', + previewUrl: 'http://localhost:8081', + }, + ) + } + resolve() + } + + const proc = spawn('claude', args, { + cwd: projectDir, + // Inherit env so the CLI can locate ~/.claude/ + env: { ...process.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + // ----------------------------------------------------------------------- + // stdout — parse newline-delimited JSON from --output-format stream-json + // ----------------------------------------------------------------------- + proc.stdout.on('data', (chunk: Buffer) => { + lineBuffer += chunk.toString('utf8') + const lines = lineBuffer.split('\n') + // Keep the last (potentially incomplete) line in the buffer + lineBuffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + + let msg: CLIStreamMessage + try { + msg = JSON.parse(trimmed) as CLIStreamMessage + } catch { + // Not valid JSON — forward as plain text (may be CLI warnings, etc.) + callbacks.onMessage(trimmed) + continue + } + + this.handleStreamMessage( + msg, + callbacks, + (sid) => { + capturedSessionId = sid + }, + (result) => { + completionResult = result + }, + ) + } + }) + + // ----------------------------------------------------------------------- + // stderr — log locally, forward as error message to UI + // ----------------------------------------------------------------------- + proc.stderr.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf8').trim() + if (text) { + console.error('[LocalCLIProvider] stderr:', text) + // Don't surface every stderr line to the user — many are benign debug + // lines printed by the CLI itself. Only surface lines that look like + // real errors. + if ( + text.toLowerCase().includes('error') || + text.toLowerCase().includes('failed') || + text.toLowerCase().includes('not found') + ) { + callbacks.onMessage(`⚠️ ${text}`) + } + } + }) + + // ----------------------------------------------------------------------- + // Process exit + // ----------------------------------------------------------------------- + proc.on('close', (code) => { + // Flush any remaining buffered content + if (lineBuffer.trim()) { + try { + const msg = JSON.parse(lineBuffer.trim()) as CLIStreamMessage + this.handleStreamMessage(msg, callbacks, (sid) => { capturedSessionId = sid }, (r) => { completionResult = r }) + } catch { + callbacks.onMessage(lineBuffer.trim()) + } + } + + if (code !== 0 && !completionResult) { + done(null, `Claude CLI exited with code ${code}. Is 'claude' installed and authenticated? Run 'claude login' to set up.`) + return + } + + done(completionResult ?? { + success: code === 0, + conversationId: capturedSessionId || undefined, + summary: 'Task completed via local Claude CLI', + previewUrl: 'http://localhost:8081', + }) + }) + + proc.on('error', (err) => { + if (err.message.includes('ENOENT')) { + done( + null, + 'Claude CLI not found. Install it from https://claude.ai/download and run "claude login" to authenticate.', + ) + } else { + done(null, `Failed to spawn Claude CLI: ${err.message}`) + } + }) + }) + } + + /** + * Returns true if the `claude` binary is on the PATH and exits cleanly. + */ + async isAvailable(): Promise { + return new Promise((resolve) => { + const proc = spawn('claude', ['--version'], { stdio: 'ignore' }) + proc.on('close', (code) => resolve(code === 0)) + proc.on('error', () => resolve(false)) + }) + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Builds the argument list for the `claude` CLI subprocess. + * + * Key flags used: + * -p / --print Non-interactive mode; sends prompt and exits. + * --output-format stream-json Emit one JSON object per line as it streams. + * --system-prompt-file Load system prompt from a file. + * --model Override the model. + * --resume Resume a specific conversation by session ID. + * --allowedTools Grant tool access without interactive prompt. + */ + private buildArgs( + request: ProviderGenerationRequest, + systemPromptFilePath?: string, + ): string[] { + // Build user message, appending context as the sandboxed provider does + let message = request.userMessage + message += '\n\nCurrent working directory: .' + if (request.selectionData?.elementId) { + message += `\nSelected element: ${request.selectionData.elementId}` + } + + const args: string[] = [ + '-p', message, + '--output-format', 'stream-json', + // Allow all tools without interactive permission prompts. + // This mirrors the sandboxed provider's `permissionMode: 'bypassPermissions'`. + '--allowedTools', 'all', + ] + + if (systemPromptFilePath) { + args.push('--system-prompt-file', systemPromptFilePath) + } + + if (request.claudeModel) { + args.push('--model', request.claudeModel) + } + + if (request.sessionId) { + // --resume continues a specific session; falls back to --continue for the + // most recent session if no explicit ID is provided. + args.push('--resume', request.sessionId) + } + + return args + } + + /** + * Dispatches a parsed CLI stream-json message to the appropriate callback. + */ + private handleStreamMessage( + msg: CLIStreamMessage, + callbacks: ProviderCallbacks, + onSessionId: (id: string) => void, + onResult: (result: ProviderCompletionResult) => void, + ): void { + switch (msg.type) { + case 'system': { + // Capture session ID for multi-turn resumption + const sysMsg = msg as CLISystemMessage + if (sysMsg.session_id) { + onSessionId(sysMsg.session_id) + console.log('[LocalCLIProvider] Session ID captured:', sysMsg.session_id) + } + // Forward the raw system message so the frontend can handle it the same + // way it handles system messages from the sandboxed provider + callbacks.onMessage(JSON.stringify(msg)) + break + } + + case 'assistant': { + const assistMsg = msg as CLIAssistantMessage + for (const block of assistMsg.message?.content ?? []) { + if (block.type === 'text') { + // Forward plain text content directly — matches what the sandboxed + // provider does when it sends parsed text messages + callbacks.onMessage(JSON.stringify({ type: 'text', text: block.text })) + } else if (block.type === 'tool_use') { + // Forward tool use blocks so the frontend can render them + callbacks.onMessage( + JSON.stringify({ + type: 'tool_use', + id: block.id, + name: block.name, + input: block.input, + }), + ) + } + } + break + } + + case 'result': { + const resultMsg = msg as CLIResultMessage + const isError = resultMsg.is_error || resultMsg.subtype !== 'success' + + if (isError) { + console.error('[LocalCLIProvider] CLI reported error result:', resultMsg.result) + // Don't call onError here — let the process exit handler do it so we + // don't call done() twice if there's both a result message and a non-0 + // exit code. + } + + onResult({ + success: !isError, + conversationId: resultMsg.session_id || undefined, + summary: isError + ? `Error: ${resultMsg.result}` + : resultMsg.result || 'Task completed via local Claude CLI', + previewUrl: 'http://localhost:8081', + }) + + // Also update session ID from result message (may be the only place it appears) + if (resultMsg.session_id) { + onSessionId(resultMsg.session_id) + } + break + } + + default: + // Forward unknown message types as raw JSON for future compatibility + callbacks.onMessage(JSON.stringify(msg)) + } + } + + /** + * Resolves the working directory for this project in local mode. + * + * Priority: + * 1. `request.localProjectPath` if explicitly set + * 2. `{LOCAL_PROJECTS_PATH}/{projectId}` where LOCAL_PROJECTS_PATH defaults + * to `~/local-projects` (relative to the user's home directory) + */ + private resolveProjectDir(request: ProviderGenerationRequest): string { + if (request.localProjectPath) { + return request.localProjectPath + } + + const baseDir = + process.env.LOCAL_PROJECTS_PATH || + path.join(os.homedir(), 'local-projects') + + return path.join(baseDir, request.projectId) + } + + /** + * Writes skill SKILL.md files to `.claude/skills/{skillId}/SKILL.md` inside + * the local project directory (mirrors what the sandboxed handler writes into + * the E2B sandbox). + */ + private async writeSkillFiles( + skillIds: string[], + projectDir: string, + ): Promise { + const { getSkillTemplate, getSkillFilePath } = await import('@/lib/skills/templates') + const prodUrl = process.env.NEXT_PUBLIC_PROD_URL || 'https://capsulethis.com' + + for (const skillId of skillIds) { + const content = getSkillTemplate(skillId, prodUrl) + if (!content) { + console.warn(`[LocalCLIProvider] No template found for skill: ${skillId}`) + continue + } + + // getSkillFilePath returns an absolute sandbox path like /home/user/app/.claude/... + // We strip that and build a local path relative to projectDir instead. + const relativePath = getSkillFilePath(skillId) + .replace(/^\/home\/user\/app\//, '') + .replace(/^\//, '') + + const fullPath = path.join(projectDir, relativePath) + try { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }) + fs.writeFileSync(fullPath, content, 'utf8') + console.log(`[LocalCLIProvider] Wrote skill file: ${fullPath}`) + } catch (err) { + console.error(`[LocalCLIProvider] Failed to write skill ${skillId}:`, err) + } + } + } +} diff --git a/apps/web/lib/providers/sandboxed-provider.ts b/apps/web/lib/providers/sandboxed-provider.ts new file mode 100644 index 00000000..40f8ac03 --- /dev/null +++ b/apps/web/lib/providers/sandboxed-provider.ts @@ -0,0 +1,53 @@ +/** + * SandboxedClaudeProvider + * + * Wraps the existing ClaudeCodeService + E2B sandbox approach as an AIProvider. + * This is the default/production mode and maintains full backwards compatibility. + * + * The transport here is: + * Next.js API → ClaudeCodeService.generateAppStreaming() + * → E2B sandbox subprocess (claude-executor.ts) + * → @anthropic-ai/claude-agent-sdk query() + * → stdout "Streaming: {...}" lines piped back via onStdout callback + */ + +import type { + AIProvider, + ProviderCallbacks, + ProviderGenerationRequest, + ProviderMode, +} from './types' + +export class SandboxedClaudeProvider implements AIProvider { + readonly name = 'sandboxed-claude' + readonly mode: ProviderMode = 'sandboxed' + + // The actual generation logic lives in claude-code-service.ts and is invoked + // through claude-code-handler.ts. This class exists as a typed wrapper so the + // factory and handler have a uniform interface. + // + // Generation is NOT implemented here — the sandboxed handler + // (handleClaudeCodeGeneration in claude-code-handler.ts) calls ClaudeCodeService + // directly because it also needs sandbox lifecycle management (connect, skill + // file writing, bundle building, etc.) that can't be cleanly separated. + // + // The generate() method below is therefore a no-op stub that throws to make + // mis-use obvious. The handler bypasses this and calls ClaudeCodeService directly. + async generate( + _request: ProviderGenerationRequest, + callbacks: ProviderCallbacks, + ): Promise { + callbacks.onError( + 'SandboxedClaudeProvider.generate() should not be called directly. ' + + 'Use handleClaudeCodeGeneration() from claude-code-handler.ts instead.', + ) + } + + /** + * Returns true when both required credentials are present. + * Does NOT attempt a live connection to E2B. + */ + async isAvailable(): Promise { + return !!(process.env.ANTHROPIC_API_KEY && process.env.E2B_API_KEY) + } +} diff --git a/apps/web/lib/providers/types.ts b/apps/web/lib/providers/types.ts new file mode 100644 index 00000000..59f9d977 --- /dev/null +++ b/apps/web/lib/providers/types.ts @@ -0,0 +1,115 @@ +/** + * Provider Abstraction Layer + * + * Defines interfaces for AI code generation providers. Two modes are supported: + * + * - `sandboxed` (default): Runs Claude Agent SDK inside an E2B cloud sandbox. + * Suitable for cloud-hosted deployments. Requires ANTHROPIC_API_KEY and E2B_API_KEY. + * + * - `local-cli`: Spawns the locally-installed `claude` CLI as a subprocess using + * `claude -p "" --output-format stream-json`. No sandbox required. + * Suitable for local development. Requires a local Claude CLI installation and + * an active Claude CLI session (`claude login`). No ANTHROPIC_API_KEY needed. + * + * Set the active mode via the CLAUDE_PROVIDER environment variable. + */ + +export type ProviderMode = 'sandboxed' | 'local-cli' + +/** + * Incoming request to generate code/text via an AI provider. + */ +export interface ProviderGenerationRequest { + userMessage: string + messageId?: string + projectId: string + userId: string + isFirstMessage?: boolean + images?: string[] + imageAttachments?: Array<{ + url: string + contentType: string + name: string + size: number + }> + fileEdition?: string + selectionData?: any + /** Claude SDK session ID for multi-turn conversation resumption */ + sessionId?: string + /** Model identifier (e.g. 'claude-sonnet-4-5-20250929') */ + claudeModel?: string + /** Selected skill IDs (e.g. 'anthropic-chat', 'openai-dalle-3') */ + skills?: string[] + /** + * Absolute path to the local project directory. + * Only used by LocalCLIProvider — ignored by SandboxedProvider. + */ + localProjectPath?: string +} + +/** + * Streaming callbacks used by all providers. + */ +export interface ProviderCallbacks { + /** + * Called for each chunk of content as it is streamed. + * May be a raw JSON string (SDK message) or plain text. + */ + onMessage: (message: string) => void + /** + * Called once when the generation completes successfully. + */ + onComplete: (result: ProviderCompletionResult) => void + /** + * Called if an unrecoverable error occurs. + */ + onError: (error: string) => void +} + +/** + * Result object passed to onComplete when generation finishes. + */ +export interface ProviderCompletionResult { + success: boolean + /** Claude SDK session ID captured from the stream — used to resume multi-turn conversations */ + conversationId?: string + summary: string + filesModified?: Array<{ + path: string + action: 'created' | 'modified' | 'deleted' + content?: string + }> + /** + * For sandboxed mode: the E2B sandbox ID. + * For local-cli mode: undefined. + */ + sandboxId?: string + /** + * For sandboxed mode: the preview URL (e.g. https://.e2b.app:8081). + * For local-cli mode: the local dev server URL (e.g. http://localhost:8081). + */ + previewUrl?: string +} + +/** + * Core interface that all AI providers must implement. + */ +export interface AIProvider { + /** Human-readable provider name */ + readonly name: string + /** The mode this provider operates in */ + readonly mode: ProviderMode + /** + * Run code generation, streaming results via callbacks. + * Implementations must call exactly one of onComplete or onError before resolving. + */ + generate( + request: ProviderGenerationRequest, + callbacks: ProviderCallbacks, + ): Promise + /** + * Returns true if the provider is ready to handle requests + * (e.g. CLI installed, API key present, sandbox reachable). + */ + isAvailable(): Promise +}