diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..1569d28
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+# Engine selection (optional).
+# Auto-detection order: claude (local CLI login) -> codex (local CLI login) -> openrouter.
+# Set to force one: claude | codex | openrouter
+# CODEFOX_ENGINE=
+
+# OpenRouter API key — required only when using the openrouter engine
+# (i.e. no logged-in `claude`/`codex` CLI on PATH, or CODEFOX_ENGINE=openrouter).
+# Also used for project title generation (skipped without a key).
+# OPENROUTER_API_KEY=your_openrouter_api_key_here
+
+# Default model for the OpenRouter engine (harness engines use the CLI's default model)
+NEXT_PUBLIC_DEFAULT_MODEL=anthropic/claude-sonnet-4.5
+
+# App name
+NEXT_PUBLIC_APP_NAME=CodeFox Local
diff --git a/.gitignore b/.gitignore
index da0220b..2dd72cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
# vercel
.vercel
diff --git a/CLAUDE.md b/CLAUDE.md
index 59a7e04..c586c43 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,4 +47,64 @@ An AI-powered local website project generator that allows users to create comple
3. **Date serialization in Zustand persist**:
- Date objects are serialized to strings in localStorage
- Always handle both Date and string types when accessing `lastAccessedAt` or `createdAt`
- - Use custom deserialization in `createJSONStorage` getItem
\ No newline at end of file
+ - Use custom deserialization in `createJSONStorage` getItem
+
+4. **Loading external packages (CSS/JS) in Sandpack preview**:
+ - Sandpack runs in a browser sandbox and cannot access node_modules directly
+ - ❌ WRONG: Adding `` in `/public/index.html`
+ - ❌ WRONG: Using `@import "tailwindcss"` in CSS (requires PostCSS, not available in browser)
+ - ✅ CORRECT: Use `externalResources` option in SandpackProvider:
+ ```tsx
+
+ ```
+ - This method works for ANY external CSS/JS library (e.g., Bootstrap, Alpine.js, etc.)
+ - The resources are automatically injected into the Sandpack iframe at initialization
+
+## Modifying AI Prompt System
+
+When you need to add new context to the AI's system prompt (e.g., file organization rules, coding standards, etc.):
+
+### Steps to modify prompt:
+
+1. **Update `lib/prompts.ts`**:
+ - Add new parameter to `createSystemPrompt` function options
+ - Create a new section function (e.g., `createFileInstructionSection`)
+ - Add the section to the sections array
+
+2. **Update API route `app/api/chat/route.ts`**:
+ - Extract the new parameter from request body: `const { messages, projectId, files, newParam } = await req.json()`
+ - Pass it to `createSystemPrompt({ files, newParam })`
+
+3. **Update frontend transport `app/page.tsx`**:
+ - In the `transport` useMemo, add the new data to the return object in `body: async () => { ... }`
+ - Can use environment variables: `process.env.NEXT_PUBLIC_YOUR_VAR`
+ - Return: `{ projectId, files, newParam }`
+
+### Example: Adding file organization instructions
+
+```typescript
+// 1. lib/prompts.ts
+export function createSystemPrompt(options?: {
+ files?: string[];
+ fileInstruction?: string; // ← New parameter
+}): string {
+ // ...
+ if (options?.fileInstruction) {
+ sections.push(createFileInstructionSection(options.fileInstruction));
+ }
+}
+
+// 2. app/api/chat/route.ts
+const { messages, projectId, files, fileInstruction } = await req.json();
+const systemPrompt = createSystemPrompt({ files, fileInstruction });
+
+// 3. app/page.tsx
+body: async () => {
+ const fileInstruction = process.env.NEXT_PUBLIC_FILE_INSTRUCTION || "default rules";
+ return { projectId, files, fileInstruction };
+}
+```
\ No newline at end of file
diff --git a/README.md b/README.md
index 140c13e..c4889a4 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# CodeFox Chat 🦊
+# CodeFox Local 🦊
-A sleek, geek-style chat application with AI assistant and integrated web preview. Built with Next.js 15, Bun, and Claude Sonnet 4.5.
+A local-first AI website generator: describe the site you want, and CodeFox builds it in a live Sandpack preview, editing real project files under `~/.codefox-local/projects`. Built with Next.js 15, Bun, and the Vercel AI SDK.
## 🎨 Features
@@ -19,8 +19,13 @@ A sleek, geek-style chat application with AI assistant and integrated web previe
- **Runtime**: Bun
- **Language**: TypeScript
- **Styling**: Tailwind CSS 4
-- **AI SDK**: Vercel AI SDK v5
-- **AI Model**: Claude Sonnet 4.5 (via OpenRouter)
+- **AI SDK**: Vercel AI SDK v7
+- **Supported engines** (auto-detected in this order, first match wins):
+ 1. **Claude Code** — uses your local `claude` CLI login (subscription, no API key), default when installed and logged in
+ 2. **Codex** — uses your local `codex` CLI login
+ 3. **OpenRouter** — API fallback, needs `OPENROUTER_API_KEY`
+
+ Force a specific engine with `CODEFOX_ENGINE=claude|codex|openrouter`. The local engines run through `@ai-sdk/harness` rooted at the project directory, so the agent edits your project files directly with its own coding tools.
- **Icons**: Lucide React
- **Markdown**: react-markdown + react-syntax-highlighter
@@ -41,15 +46,21 @@ cp .env.example .env.local
## 🔑 Configuration
-Create a `.env.local` file with your OpenRouter API key:
+With a logged-in `claude` or `codex` CLI on your PATH, no configuration is needed — the engine is detected automatically. Otherwise create a `.env.local`:
```env
+# Optional: force an engine instead of auto-detection (claude | codex | openrouter)
+CODEFOX_ENGINE=
+
+# Required only for the OpenRouter engine
OPENROUTER_API_KEY=your_openrouter_api_key_here
+
+# OpenRouter model (harness engines use the CLI's default model)
NEXT_PUBLIC_DEFAULT_MODEL=anthropic/claude-sonnet-4.5
-NEXT_PUBLIC_APP_NAME=CodeFox Chat
+NEXT_PUBLIC_APP_NAME=CodeFox Local
```
-Get your OpenRouter API key from: https://openrouter.ai/
+Get an OpenRouter API key from: https://openrouter.ai/ — project title generation also uses OpenRouter and is skipped without a key.
## 🎯 Usage
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index c999be4..11e3098 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -1,37 +1,50 @@
-import { streamText, convertToModelMessages } from "ai";
-import { createOpenRouter } from "@openrouter/ai-sdk-provider";
-import { spawn } from "child_process";
-import { ProjectManager } from "@/lib/project-manager";
-import {
- defineServerSideTool,
- defineClientSideTool,
- writeFileSchema,
- executeCommandSchema,
- tryStartDevServerSchema,
- type WriteFileOutput,
- type ExecuteCommandOutput,
-} from "@/lib/tool-definitions";
+import { existsSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from "ai";
+import { createOpenAI } from "@ai-sdk/openai";
import { createSystemPrompt } from "@/lib/prompts";
+import { clientSideTools } from "@/lib/client-tools-definitions";
+import { resolveEngine } from "@/lib/engine/resolve";
+import { streamHarnessTurn } from "@/lib/engine/harness";
+import { ProjectManager } from "@/lib/project-manager";
-const projectManager = ProjectManager.getInstance();
+// The harness bridge spawns a local child process — this route must run in
+// the Node.js runtime, never edge.
+export const runtime = "nodejs";
-// Create OpenRouter client
-const openrouter = createOpenRouter({
+// OpenRouter speaks the OpenAI chat-completions API; @ai-sdk/openai is the
+// ai@7-compatible client for it (@openrouter/ai-sdk-provider peers ai@6).
+const openrouter = createOpenAI({
+ baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY || "",
});
+/** Resolve the on-disk project directory for a projectId. */
+function resolveProjectDir(projectId: string): string | null {
+ const known = ProjectManager.getInstance().getProject(projectId)?.path;
+ if (known) return known;
+ // ProjectManager state is in-memory; fall back to the on-disk layout.
+ const fallback = path.join(os.homedir(), ".codefox-local", "projects", projectId);
+ return existsSync(fallback) ? fallback : null;
+}
+
+/** Newest user message text — the harness session is stateful, it only needs the latest turn. */
+function lastUserText(messages: UIMessage[]): string {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role !== "user") continue;
+ return message.parts
+ .filter((part): part is Extract => part.type === "text")
+ .map((part) => part.text)
+ .join("\n");
+ }
+ return "";
+}
export async function POST(req: Request) {
try {
- const { messages, projectId } = await req.json();
-
- // Validate API key
- if (!process.env.OPENROUTER_API_KEY) {
- return new Response(
- JSON.stringify({ error: "OpenRouter API key not configured" }),
- { status: 500, headers: { "Content-Type": "application/json" } }
- );
- }
+ const { messages, projectId, files, fileContents, fileInstruction } = await req.json();
// Validate project ID
if (!projectId) {
@@ -41,151 +54,45 @@ export async function POST(req: Request) {
);
}
- // Convert UIMessages to ModelMessages
- const modelMessages = convertToModelMessages(messages);
+ const engine = resolveEngine();
- // Helper function to extract URL from dev server output
- const extractUrl = (output: string): string | null => {
- const patterns = [
- /Local:\s+(https?:\/\/[^\s]+)/, // Vite
- /url:\s+(https?:\/\/[^\s]+)/, // Next.js
- /(https?:\/\/localhost:\d+)/, // Generic
- ];
-
- for (const pattern of patterns) {
- const match = output.match(pattern);
- if (match) return match[1].replace(/\/$/, ''); // Remove trailing slash
+ if (engine === "claude" || engine === "codex") {
+ const projectDir = resolveProjectDir(projectId);
+ if (!projectDir) {
+ return new Response(
+ JSON.stringify({ error: `Project ${projectId} not found on disk` }),
+ { status: 404, headers: { "Content-Type": "application/json" } }
+ );
}
+ const prompt = lastUserText(messages);
+ if (!prompt) {
+ return new Response(
+ JSON.stringify({ error: "No user message to send" }),
+ { status: 400, headers: { "Content-Type": "application/json" } }
+ );
+ }
+ // The harness runtime owns its own coding tools and works on the
+ // project directory directly — no client-side tools, no file snapshot.
+ return await streamHarnessTurn({ vendor: engine, projectDir, prompt });
+ }
- return null;
- };
-
- // TODO: When moving to remote/cloud deployment, these tools should be proxied
- // to a VM sandbox environment for security and isolation. The right panel
- // should connect to the sandbox's dev server instead of localhost.
- //
- // Architecture for remote deployment:
- // 1. User request → Chat API
- // 2. Chat API → VM Sandbox Proxy
- // 3. VM Sandbox executes tools (writeFile, executeCommand)
- // 4. Right panel iframe → VM Sandbox dev server (e.g., https://sandbox-{id}.example.com)
- //
- // For now, these tools execute on the local server where the app is running.
- const tools = {
- writeFile: defineServerSideTool({
- description: "Write content to a file in the project. Creates directories if needed.",
- inputSchema: writeFileSchema,
- execute: async (input): Promise => {
- try {
- await projectManager.writeFile(projectId, input.path, input.content);
- return {
- success: true,
- message: `File ${input.path} written successfully`,
- };
- } catch (error) {
- return {
- success: false,
- error: error instanceof Error ? error.message : "Unknown error",
- };
- }
- },
- }),
- executeCommand: defineServerSideTool({
- description: "Execute a shell command in the project directory. For dev servers (npm run dev), use keepAlive=true to run in background.",
- inputSchema: executeCommandSchema,
- execute: async (input): Promise => {
- const { command, keepAlive = false } = input;
- try {
- if (keepAlive) {
- // Start dev server in background
- const projectPath = projectManager.getProjectPath(projectId);
-
- return new Promise((resolve, reject) => {
- const proc = spawn(command, {
- cwd: projectPath,
- shell: true,
- detached: true,
- stdio: ['ignore', 'pipe', 'pipe'],
- });
-
- let output = '';
-
- // Collect output
- proc.stdout?.on('data', (data: Buffer) => {
- output += data.toString();
-
- // Try to extract URL
- const url = extractUrl(output);
- if (url) {
- proc.unref(); // Detach so it keeps running
- resolve({
- success: true,
- stdout: output,
- previewUrl: url,
- pid: proc.pid,
- message: `Dev server started at ${url}`,
- });
- }
- });
-
- proc.stderr?.on('data', (data: Buffer) => {
- output += data.toString();
-
- const url = extractUrl(output);
- if (url) {
- proc.unref();
- resolve({
- success: true,
- stdout: output,
- previewUrl: url,
- pid: proc.pid,
- message: `Dev server started at ${url}`,
- });
- }
- });
+ // OpenRouter path
+ if (!process.env.OPENROUTER_API_KEY) {
+ return new Response(
+ JSON.stringify({ error: "OpenRouter API key not configured" }),
+ { status: 500, headers: { "Content-Type": "application/json" } }
+ );
+ }
- proc.on('error', (error) => {
- reject({
- success: false,
- error: `Failed to start: ${error.message}`,
- });
- });
+ // Convert UIMessages to ModelMessages
+ const modelMessages = await convertToModelMessages(messages);
- // Timeout - commented out to allow any type of background job
- // setTimeout(() => {
- // if (!extractUrl(output)) {
- // proc.kill();
- // reject({
- // success: false,
- // error: 'Server started but no URL found',
- // stdout: output.substring(0, 500),
- // });
- // }
- // }, timeout);
- });
- } else {
- // Normal execution
- const result = await projectManager.executeCommand(projectId, command);
- return {
- success: true,
- stdout: result.stdout,
- stderr: result.stderr,
- };
- }
- } catch (error) {
- return {
- success: false,
- error: error instanceof Error ? error.message : "Unknown error",
- };
- }
- },
- }),
- // Client-side tool: tryStartDevServer
- tryStartDevServer: defineClientSideTool({
- description: "Try to start the development server for the current project. This will run 'bun install && bun dev' and automatically detect the server URL. Call this tool when you have completed your work and want to preview the result.",
- inputSchema: tryStartDevServerSchema,
- }),
- };
+ // Create system prompt with file contents and organization instructions
+ const systemPrompt = createSystemPrompt({
+ files,
+ fileContents,
+ fileInstruction
+ });
// Stream the response with tools
const result = streamText({
@@ -193,12 +100,14 @@ export async function POST(req: Request) {
process.env.NEXT_PUBLIC_DEFAULT_MODEL || "anthropic/claude-sonnet-4.5"
),
messages: modelMessages,
- system: createSystemPrompt(),
- tools,
+ system: systemPrompt,
+ tools: clientSideTools,
temperature: 0.7,
+ maxOutputTokens: 16384,
+ stopWhen: stepCountIs(100),
});
- // Return UIMessage stream response (AI SDK v5)
+ // Return UIMessage stream response
return result.toUIMessageStreamResponse();
} catch (error) {
console.error("Chat API error:", error);
diff --git a/app/api/project/file/route.ts b/app/api/project/file/route.ts
new file mode 100644
index 0000000..64a6018
--- /dev/null
+++ b/app/api/project/file/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ProjectManager } from '@/lib/project-manager';
+
+const projectManager = ProjectManager.getInstance();
+
+export async function GET(req: NextRequest) {
+ try {
+ const { searchParams } = new URL(req.url);
+ const projectId = searchParams.get('projectId');
+ const filePath = searchParams.get('filePath');
+
+ if (!projectId || typeof projectId !== 'string') {
+ return NextResponse.json(
+ { success: false, error: 'projectId is required' },
+ { status: 400 }
+ );
+ }
+
+ if (!filePath || typeof filePath !== 'string') {
+ return NextResponse.json(
+ { success: false, error: 'filePath is required' },
+ { status: 400 }
+ );
+ }
+
+ const content = await projectManager.readFile(projectId, filePath);
+
+ return NextResponse.json({
+ success: true,
+ content
+ });
+ } catch (error) {
+ console.error('[api/project/file] Error reading file:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/app/api/project/files/route.ts b/app/api/project/files/route.ts
new file mode 100644
index 0000000..ae21b5a
--- /dev/null
+++ b/app/api/project/files/route.ts
@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ProjectManager } from '@/lib/project-manager';
+
+const projectManager = ProjectManager.getInstance();
+
+export async function GET(req: NextRequest) {
+ try {
+ const { searchParams } = new URL(req.url);
+ const projectId = searchParams.get('projectId');
+
+ if (!projectId || typeof projectId !== 'string') {
+ return NextResponse.json(
+ { success: false, error: 'projectId is required' },
+ { status: 400 }
+ );
+ }
+
+ const files = await projectManager.listFiles(projectId);
+
+ return NextResponse.json({
+ success: true,
+ files
+ });
+ } catch (error) {
+ console.error('[api/project/files] Error listing files:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/app/api/project/title/route.ts b/app/api/project/title/route.ts
index a21174f..7febfa1 100644
--- a/app/api/project/title/route.ts
+++ b/app/api/project/title/route.ts
@@ -1,16 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { ProjectManager } from '@/lib/project-manager';
-import { createOpenRouter } from '@openrouter/ai-sdk-provider';
+import { createOpenAI } from '@ai-sdk/openai';
import { generateText } from 'ai';
const projectManager = ProjectManager.getInstance();
-const openrouter = createOpenRouter({
+const openrouter = createOpenAI({
+ baseURL: 'https://openrouter.ai/api/v1',
apiKey: process.env.OPENROUTER_API_KEY || '',
});
async function buildTitle(prompt: string): Promise {
if (!prompt) return null;
+ // Title generation stays on OpenRouter; without a key it degrades to no title.
+ if (!process.env.OPENROUTER_API_KEY) return null;
try {
const result = await generateText({
diff --git a/app/layout.tsx b/app/layout.tsx
index 44a9227..d9e1dd7 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
-import { ThemeProvider } from "@/components/theme-provider";
+import { Providers } from "@/components/providers";
import "./globals.css";
const geistSans = Geist({
@@ -29,15 +29,10 @@ export default function RootLayout({
-
+
{children}
-
+