Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<projectId>
# 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/<projectId>
# Defaults to ~/local-projects when not set.
LOCAL_PROJECTS_PATH=

### Optional env vars

# Domain of the site
Expand Down
15 changes: 13 additions & 2 deletions apps/web/lib/claude-code-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void> {
// 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,
Expand Down
56 changes: 56 additions & 0 deletions apps/web/lib/providers/factory.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
17 changes: 17 additions & 0 deletions apps/web/lib/providers/index.ts
Original file line number Diff line number Diff line change
@@ -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'
156 changes: 156 additions & 0 deletions apps/web/lib/providers/local-cli-handler.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
},
})
}
Loading