Merge branch 'main' into pull-request

This commit is contained in:
Cody Seibert
2025-12-19 16:53:00 -05:00
325 changed files with 15335 additions and 14012 deletions

View File

@@ -1,5 +0,0 @@
module.exports = {
rules: {
"@typescript-eslint/no-require-imports": "off",
},
};

View File

@@ -1,37 +0,0 @@
/**
* Simplified Electron preload script
*
* Only exposes native features (dialogs, shell) and server URL.
* All other operations go through HTTP API.
*/
const { contextBridge, ipcRenderer } = require("electron");
// Expose minimal API for native features
contextBridge.exposeInMainWorld("electronAPI", {
// Platform info
platform: process.platform,
isElectron: true,
// Connection check
ping: () => ipcRenderer.invoke("ping"),
// Get server URL for HTTP client
getServerUrl: () => ipcRenderer.invoke("server:getUrl"),
// Native dialogs - better UX than prompt()
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
saveFile: (options) => ipcRenderer.invoke("dialog:saveFile", options),
// Shell operations
openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url),
openPath: (filePath) => ipcRenderer.invoke("shell:openPath", filePath),
// App info
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
getVersion: () => ipcRenderer.invoke("app:getVersion"),
isPackaged: () => ipcRenderer.invoke("app:isPackaged"),
});
console.log("[Preload] Electron API exposed (simplified mode)");

View File

@@ -1,20 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Electron files use CommonJS
"electron/**",
]),
]);
export default eslintConfig;

View File

@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;

View File

@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -1,97 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
interface AnthropicResponse {
content?: Array<{ type: string; text?: string }>;
model?: string;
error?: { message?: string };
}
export async function POST(request: NextRequest) {
try {
const { apiKey } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY;
if (!effectiveApiKey) {
return NextResponse.json(
{ success: false, error: "No API key provided or configured in environment" },
{ status: 400 }
);
}
// Send a simple test prompt to the Anthropic API
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": effectiveApiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 100,
messages: [
{
role: "user",
content: "Respond with exactly: 'Claude API connection successful!' and nothing else.",
},
],
}),
});
if (!response.ok) {
const errorData = (await response.json()) as AnthropicResponse;
const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
if (response.status === 401) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
{ status: 401 }
);
}
if (response.status === 429) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ success: false, error: `API error: ${errorMessage}` },
{ status: response.status }
);
}
const data = (await response.json()) as AnthropicResponse;
// Check if we got a valid response
if (data.content && data.content.length > 0) {
const textContent = data.content.find((block) => block.type === "text");
if (textContent && textContent.type === "text" && textContent.text) {
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${textContent.text}"`,
model: data.model,
});
}
}
return NextResponse.json({
success: true,
message: "Connection successful! Claude responded.",
model: data.model,
});
} catch (error: unknown) {
console.error("Claude API test error:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Claude API";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -1,191 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
interface GeminiContent {
parts: Array<{
text?: string;
inlineData?: {
mimeType: string;
data: string;
};
}>;
role?: string;
}
interface GeminiRequest {
contents: GeminiContent[];
generationConfig?: {
maxOutputTokens?: number;
temperature?: number;
};
}
interface GeminiResponse {
candidates?: Array<{
content: {
parts: Array<{
text: string;
}>;
role: string;
};
finishReason: string;
safetyRatings?: Array<{
category: string;
probability: string;
}>;
}>;
promptFeedback?: {
safetyRatings?: Array<{
category: string;
probability: string;
}>;
};
error?: {
code: number;
message: string;
status: string;
};
}
export async function POST(request: NextRequest) {
try {
const { apiKey, imageData, mimeType, prompt } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.GOOGLE_API_KEY;
if (!effectiveApiKey) {
return NextResponse.json(
{ success: false, error: "No API key provided or configured in environment" },
{ status: 400 }
);
}
// Build the request body
const requestBody: GeminiRequest = {
contents: [
{
parts: [],
},
],
generationConfig: {
maxOutputTokens: 150,
temperature: 0.4,
},
};
// Add image if provided
if (imageData && mimeType) {
requestBody.contents[0].parts.push({
inlineData: {
mimeType: mimeType,
data: imageData,
},
});
}
// Add text prompt
const textPrompt = prompt || (imageData
? "Describe what you see in this image briefly."
: "Respond with exactly: 'Gemini SDK connection successful!' and nothing else.");
requestBody.contents[0].parts.push({
text: textPrompt,
});
// Call Gemini API - using gemini-1.5-flash as it supports both text and vision
const model = imageData ? "gemini-1.5-flash" : "gemini-1.5-flash";
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${effectiveApiKey}`;
const response = await fetch(geminiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const data: GeminiResponse = await response.json();
// Check for API errors
if (data.error) {
const errorMessage = data.error.message || "Unknown Gemini API error";
const statusCode = data.error.code || 500;
if (statusCode === 400 && errorMessage.includes("API key")) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Google API key." },
{ status: 401 }
);
}
if (statusCode === 429) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ success: false, error: `API error: ${errorMessage}` },
{ status: statusCode }
);
}
// Check for valid response
if (!response.ok) {
return NextResponse.json(
{ success: false, error: `HTTP error: ${response.status} ${response.statusText}` },
{ status: response.status }
);
}
// Extract response text
if (data.candidates && data.candidates.length > 0 && data.candidates[0].content?.parts?.length > 0) {
const responseText = data.candidates[0].content.parts
.filter((part) => part.text)
.map((part) => part.text)
.join("");
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${responseText.substring(0, 200)}${responseText.length > 200 ? '...' : ''}"`,
model: model,
hasImage: !!imageData,
});
}
// Handle blocked responses
if (data.promptFeedback?.safetyRatings) {
return NextResponse.json({
success: true,
message: "Connection successful! Gemini responded (response may have been filtered).",
model: model,
hasImage: !!imageData,
});
}
return NextResponse.json({
success: true,
message: "Connection successful! Gemini responded.",
model: model,
hasImage: !!imageData,
});
} catch (error: unknown) {
console.error("Gemini API test error:", error);
if (error instanceof TypeError && error.message.includes("fetch")) {
return NextResponse.json(
{ success: false, error: "Network error. Unable to reach Gemini API." },
{ status: 503 }
);
}
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Gemini API";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,26 +0,0 @@
import type { Metadata } from "next";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import { Toaster } from "sonner";
import "./globals.css";
export const metadata: Metadata = {
title: "Automaker - Autonomous AI Development Studio",
description: "Build software autonomously with intelligent orchestration",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
>
{children}
<Toaster richColors position="bottom-right" />
</body>
</html>
);
}

View File

@@ -1,234 +0,0 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { WelcomeView } from "@/components/views/welcome-view";
import { BoardView } from "@/components/views/board-view";
import { SpecView } from "@/components/views/spec-view";
import { AgentView } from "@/components/views/agent-view";
import { SettingsView } from "@/components/views/settings-view";
import { InterviewView } from "@/components/views/interview-view";
import { ContextView } from "@/components/views/context-view";
import { ProfilesView } from "@/components/views/profiles-view";
import { SetupView } from "@/components/views/setup-view";
import { RunningAgentsView } from "@/components/views/running-agents-view";
import { TerminalView } from "@/components/views/terminal-view";
import { WikiView } from "@/components/views/wiki-view";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
import {
FileBrowserProvider,
useFileBrowser,
setGlobalFileBrowser,
} from "@/contexts/file-browser-context";
function HomeContent() {
const {
currentView,
setCurrentView,
setIpcConnected,
theme,
currentProject,
previewTheme,
getEffectiveTheme,
} = useAppStore();
const { isFirstRun, setupComplete } = useSetupStore();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const { openFileBrowser } = useFileBrowser();
// Hidden streamer panel - opens with "\" key
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
// Don't trigger when typing in inputs
const activeElement = document.activeElement;
if (activeElement) {
const tagName = activeElement.tagName.toLowerCase();
if (
tagName === "input" ||
tagName === "textarea" ||
tagName === "select"
) {
return;
}
if (activeElement.getAttribute("contenteditable") === "true") {
return;
}
const role = activeElement.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
return;
}
}
// Don't trigger with modifier keys
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
// Check for "\" key (backslash)
if (event.key === "\\") {
event.preventDefault();
setStreamerPanelOpen((prev) => !prev);
}
}, []);
// Register the "\" shortcut for streamer panel
useEffect(() => {
window.addEventListener("keydown", handleStreamerPanelShortcut);
return () => {
window.removeEventListener("keydown", handleStreamerPanelShortcut);
};
}, [handleStreamerPanelShortcut]);
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
const effectiveTheme = getEffectiveTheme();
// Prevent hydration issues
useEffect(() => {
setIsMounted(true);
}, []);
// Initialize global file browser for HttpApiClient
useEffect(() => {
setGlobalFileBrowser(openFileBrowser);
}, [openFileBrowser]);
// Check if this is first run and redirect to setup if needed
useEffect(() => {
console.log("[Setup Flow] Checking setup state:", {
isMounted,
isFirstRun,
setupComplete,
currentView,
shouldShowSetup: isMounted && isFirstRun && !setupComplete,
});
if (isMounted && isFirstRun && !setupComplete) {
console.log(
"[Setup Flow] Redirecting to setup wizard (first run, not complete)"
);
setCurrentView("setup");
} else if (isMounted && setupComplete) {
console.log("[Setup Flow] Setup already complete, showing normal view");
}
}, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]);
// Test IPC connection on mount
useEffect(() => {
const testConnection = async () => {
try {
const api = getElectronAPI();
const result = await api.ping();
setIpcConnected(result === "pong");
} catch (error) {
console.error("IPC connection failed:", error);
setIpcConnected(false);
}
};
testConnection();
}, [setIpcConnected]);
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
useEffect(() => {
const root = document.documentElement;
const themeClasses = [
"dark",
"light",
"retro",
"dracula",
"nord",
"monokai",
"tokyonight",
"solarized",
"gruvbox",
"catppuccin",
"onedark",
"synthwave",
"red",
"cream",
"sunset",
"gray",
];
// Remove all theme classes
root.classList.remove(...themeClasses);
// Apply the effective theme
if (themeClasses.includes(effectiveTheme)) {
root.classList.add(effectiveTheme);
} else if (effectiveTheme === "system") {
// System theme - detect OS preference
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.add(isDark ? "dark" : "light");
}
}, [effectiveTheme, previewTheme, currentProject, theme]);
const renderView = () => {
switch (currentView) {
case "welcome":
return <WelcomeView />;
case "setup":
return <SetupView />;
case "board":
return <BoardView />;
case "spec":
return <SpecView />;
case "agent":
return <AgentView />;
case "settings":
return <SettingsView />;
case "interview":
return <InterviewView />;
case "context":
return <ContextView />;
case "profiles":
return <ProfilesView />;
case "running-agents":
return <RunningAgentsView />;
case "terminal":
return <TerminalView />;
case "wiki":
return <WikiView />;
default:
return <WelcomeView />;
}
};
// Setup view is full-screen without sidebar
if (currentView === "setup") {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<SetupView />
</main>
);
}
return (
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<Sidebar />
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? "250px" : "0" }}
>
{renderView()}
</div>
{/* Hidden streamer panel - opens with "\" key, pushes content */}
<div
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
streamerPanelOpen ? "translate-x-0" : "translate-x-full"
}`}
/>
</main>
);
}
export default function Home() {
return (
<FileBrowserProvider>
<HomeContent />
</FileBrowserProvider>
);
}

View File

@@ -1,88 +0,0 @@
"use client";
import * as React from "react";
import { Sparkles, X } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface CoursePromoBadgeProps {
sidebarOpen?: boolean;
}
export function CoursePromoBadge({ sidebarOpen = true }: CoursePromoBadgeProps) {
const [dismissed, setDismissed] = React.useState(false);
if (dismissed) {
return null;
}
// Collapsed state - show only icon with tooltip
if (!sidebarOpen) {
return (
<div className="p-2 pb-0 flex justify-center">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://agenticjumpstart.com"
target="_blank"
rel="noopener noreferrer"
className="group cursor-pointer flex items-center justify-center w-10 h-10 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-all border border-primary/30"
data-testid="course-promo-badge-collapsed"
>
<Sparkles className="size-4 shrink-0" />
</a>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2">
<span>Become a 10x Dev</span>
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDismissed(true);
}}
className="p-0.5 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
aria-label="Dismiss"
>
<X className="size-3" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
// Expanded state - show full badge
return (
<div className="p-2 pb-0">
<a
href="https://agenticjumpstart.com"
target="_blank"
rel="noopener noreferrer"
className="group cursor-pointer flex items-center justify-between w-full px-2 lg:px-3 py-2.5 bg-primary/10 text-primary rounded-lg font-medium text-sm hover:bg-primary/20 transition-all border border-primary/30"
data-testid="course-promo-badge"
>
<div className="flex items-center gap-2">
<Sparkles className="size-4 shrink-0" />
<span className="hidden lg:block">Become a 10x Dev</span>
</div>
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDismissed(true);
}}
className="hidden lg:block p-1 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
aria-label="Dismiss"
>
<X className="size-3.5" />
</span>
</a>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View File

@@ -18,24 +18,24 @@
"test:unit": "vitest run tests/unit"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
"@anthropic-ai/claude-agent-sdk": "^0.1.72",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express": "^5.2.1",
"morgan": "^1.10.1",
"node-pty": "1.1.0-beta41",
"ws": "^8.18.0"
"ws": "^8.18.3"
},
"devDependencies": {
"@types/cors": "^2.8.18",
"@types/express": "^5.0.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/morgan": "^1.9.10",
"@types/node": "^20",
"@types/node": "^22",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.15",
"@vitest/ui": "^4.0.15",
"tsx": "^4.19.4",
"@vitest/coverage-v8": "^4.0.16",
"@vitest/ui": "^4.0.16",
"tsx": "^4.21.0",
"typescript": "^5",
"vitest": "^4.0.15"
"vitest": "^4.0.16"
}
}

View File

@@ -4,6 +4,235 @@
* This format must be included in all prompts that generate, modify, or regenerate
* app specifications to ensure consistency across the application.
*/
/**
* TypeScript interface for structured spec output
*/
export interface SpecOutput {
project_name: string;
overview: string;
technology_stack: string[];
core_capabilities: string[];
implemented_features: Array<{
name: string;
description: string;
file_locations?: string[];
}>;
additional_requirements?: string[];
development_guidelines?: string[];
implementation_roadmap?: Array<{
phase: string;
status: "completed" | "in_progress" | "pending";
description: string;
}>;
}
/**
* JSON Schema for structured spec output
* Used with Claude's structured output feature for reliable parsing
*/
export const specOutputSchema = {
type: "object",
properties: {
project_name: {
type: "string",
description: "The name of the project",
},
overview: {
type: "string",
description:
"A comprehensive description of what the project does, its purpose, and key goals",
},
technology_stack: {
type: "array",
items: { type: "string" },
description:
"List of all technologies, frameworks, libraries, and tools used",
},
core_capabilities: {
type: "array",
items: { type: "string" },
description: "List of main features and capabilities the project provides",
},
implemented_features: {
type: "array",
items: {
type: "object",
properties: {
name: {
type: "string",
description: "Name of the implemented feature",
},
description: {
type: "string",
description: "Description of what the feature does",
},
file_locations: {
type: "array",
items: { type: "string" },
description: "File paths where this feature is implemented",
},
},
required: ["name", "description"],
},
description: "Features that have been implemented based on code analysis",
},
additional_requirements: {
type: "array",
items: { type: "string" },
description: "Any additional requirements or constraints",
},
development_guidelines: {
type: "array",
items: { type: "string" },
description: "Development standards and practices",
},
implementation_roadmap: {
type: "array",
items: {
type: "object",
properties: {
phase: {
type: "string",
description: "Name of the implementation phase",
},
status: {
type: "string",
enum: ["completed", "in_progress", "pending"],
description: "Current status of this phase",
},
description: {
type: "string",
description: "Description of what this phase involves",
},
},
required: ["phase", "status", "description"],
},
description: "Phases or roadmap items for implementation",
},
},
required: [
"project_name",
"overview",
"technology_stack",
"core_capabilities",
"implemented_features",
],
additionalProperties: false,
};
/**
* Escape special XML characters
*/
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Convert structured spec output to XML format
*/
export function specToXml(spec: SpecOutput): string {
const indent = " ";
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
${indent}<project_name>${escapeXml(spec.project_name)}</project_name>
${indent}<overview>
${indent}${indent}${escapeXml(spec.overview)}
${indent}</overview>
${indent}<technology_stack>
${spec.technology_stack.map((t) => `${indent}${indent}<technology>${escapeXml(t)}</technology>`).join("\n")}
${indent}</technology_stack>
${indent}<core_capabilities>
${spec.core_capabilities.map((c) => `${indent}${indent}<capability>${escapeXml(c)}</capability>`).join("\n")}
${indent}</core_capabilities>
${indent}<implemented_features>
${spec.implemented_features
.map(
(f) => `${indent}${indent}<feature>
${indent}${indent}${indent}<name>${escapeXml(f.name)}</name>
${indent}${indent}${indent}<description>${escapeXml(f.description)}</description>${
f.file_locations && f.file_locations.length > 0
? `\n${indent}${indent}${indent}<file_locations>
${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}<location>${escapeXml(loc)}</location>`).join("\n")}
${indent}${indent}${indent}</file_locations>`
: ""
}
${indent}${indent}</feature>`
)
.join("\n")}
${indent}</implemented_features>`;
// Optional sections
if (spec.additional_requirements && spec.additional_requirements.length > 0) {
xml += `
${indent}<additional_requirements>
${spec.additional_requirements.map((r) => `${indent}${indent}<requirement>${escapeXml(r)}</requirement>`).join("\n")}
${indent}</additional_requirements>`;
}
if (spec.development_guidelines && spec.development_guidelines.length > 0) {
xml += `
${indent}<development_guidelines>
${spec.development_guidelines.map((g) => `${indent}${indent}<guideline>${escapeXml(g)}</guideline>`).join("\n")}
${indent}</development_guidelines>`;
}
if (spec.implementation_roadmap && spec.implementation_roadmap.length > 0) {
xml += `
${indent}<implementation_roadmap>
${spec.implementation_roadmap
.map(
(r) => `${indent}${indent}<phase>
${indent}${indent}${indent}<name>${escapeXml(r.phase)}</name>
${indent}${indent}${indent}<status>${escapeXml(r.status)}</status>
${indent}${indent}${indent}<description>${escapeXml(r.description)}</description>
${indent}${indent}</phase>`
)
.join("\n")}
${indent}</implementation_roadmap>`;
}
xml += `
</project_specification>`;
return xml;
}
/**
* Get prompt instruction for structured output (simpler than XML instructions)
*/
export function getStructuredSpecPromptInstruction(): string {
return `
Analyze the project and provide a comprehensive specification with:
1. **project_name**: The name of the project
2. **overview**: A comprehensive description of what the project does, its purpose, and key goals
3. **technology_stack**: List all technologies, frameworks, libraries, and tools used
4. **core_capabilities**: List the main features and capabilities the project provides
5. **implemented_features**: For each implemented feature, provide:
- name: Feature name
- description: What it does
- file_locations: Key files where it's implemented (optional)
6. **additional_requirements**: Any system requirements, dependencies, or constraints (optional)
7. **development_guidelines**: Development standards and best practices (optional)
8. **implementation_roadmap**: Project phases with status (completed/in_progress/pending) (optional)
Be thorough in your analysis. The output will be automatically formatted as structured JSON.
`;
}
export const APP_SPEC_XML_FORMAT = `
The app_spec.txt file MUST follow this exact XML format:
@@ -63,10 +292,11 @@ export function getAppSpecFormatInstruction(): string {
${APP_SPEC_XML_FORMAT}
CRITICAL FORMATTING REQUIREMENTS:
- Do NOT use the Write, Edit, or Bash tools to create files - just OUTPUT the XML in your response
- Your ENTIRE response MUST be valid XML following the exact template structure above
- Do NOT use markdown formatting (no # headers, no **bold**, no - lists, etc.)
- Do NOT include any explanatory text, prefix, or suffix outside the XML tags
- Do NOT include phrases like "Based on my analysis..." or "I'll create..." before the XML
- Do NOT include phrases like "Based on my analysis...", "I'll create...", "Let me analyze..." before the XML
- Do NOT include any text before <project_specification> or after </project_specification>
- Your response must start IMMEDIATELY with <project_specification> with no preceding text
- Your response must end IMMEDIATELY with </project_specification> with no following text

View File

@@ -53,6 +53,13 @@ export function getImagesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "images");
}
/**
* Get the context files directory for a project (user-added context files)
*/
export function getContextDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "context");
}
/**
* Get the worktrees metadata directory for a project
*/

View File

@@ -58,13 +58,13 @@ export const TOOL_PRESETS = {
*/
export const MAX_TURNS = {
/** Quick operations that shouldn't need many iterations */
quick: 5,
quick: 50,
/** Standard operations */
standard: 20,
standard: 100,
/** Long-running operations like full spec generation */
extended: 50,
extended: 250,
/** Very long operations that may require extensive exploration */
maximum: 1000,
@@ -143,6 +143,12 @@ export interface CreateSdkOptionsConfig {
/** Optional abort controller for cancellation */
abortController?: AbortController;
/** Optional output format for structured outputs */
outputFormat?: {
type: "json_schema";
schema: Record<string, unknown>;
};
}
/**
@@ -158,12 +164,17 @@ export function createSpecGenerationOptions(
): Options {
return {
...getBaseOptions(),
// Override permissionMode - spec generation only needs read-only tools
// Using "acceptEdits" can cause Claude to write files to unexpected locations
// See: https://github.com/AutoMaker-Org/automaker/issues/149
permissionMode: "default",
model: getModelForUseCase("spec", config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration],
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }),
};
}
@@ -180,6 +191,8 @@ export function createFeatureGenerationOptions(
): Options {
return {
...getBaseOptions(),
// Override permissionMode - feature generation only needs read-only tools
permissionMode: "default",
model: getModelForUseCase("features", config.model),
maxTurns: MAX_TURNS.quick,
cwd: config.cwd,
@@ -194,7 +207,7 @@ export function createFeatureGenerationOptions(
*
* Configuration:
* - Uses read-only tools for analysis
* - Quick turns for focused suggestions
* - Standard turns to allow thorough codebase exploration and structured output generation
* - Opus model by default for thorough analysis
*/
export function createSuggestionsOptions(
@@ -203,11 +216,12 @@ export function createSuggestionsOptions(
return {
...getBaseOptions(),
model: getModelForUseCase("suggestions", config.model),
maxTurns: MAX_TURNS.quick,
maxTurns: MAX_TURNS.extended,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }),
};
}

View File

@@ -6,7 +6,12 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import { getAppSpecFormatInstruction } from "../../lib/app-spec-format.js";
import {
specOutputSchema,
specToXml,
getStructuredSpecPromptInstruction,
type SpecOutput,
} from "../../lib/app-spec-format.js";
import { createLogger } from "../../lib/logger.js";
import { createSpecGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js";
@@ -38,7 +43,7 @@ export async function generateSpec(
if (analyzeProject !== false) {
// Default to true - analyze the project
analysisInstructions = `Based on this overview, analyze the project directory (if it exists) and create a comprehensive specification. Use the Read, Glob, and Grep tools to explore the codebase and understand:
analysisInstructions = `Based on this overview, analyze the project directory (if it exists) using the Read, Glob, and Grep tools to understand:
- Existing technologies and frameworks
- Project structure and architecture
- Current features and capabilities
@@ -66,7 +71,7 @@ ${techStackDefaults}
${analysisInstructions}
${getAppSpecFormatInstruction()}`;
${getStructuredSpecPromptInstruction()}`;
logger.info("========== PROMPT BEING SENT ==========");
logger.info(`Prompt length: ${prompt.length} chars`);
@@ -81,6 +86,10 @@ ${getAppSpecFormatInstruction()}`;
const options = createSpecGenerationOptions({
cwd: projectPath,
abortController,
outputFormat: {
type: "json_schema",
schema: specOutputSchema,
},
});
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
@@ -101,6 +110,7 @@ ${getAppSpecFormatInstruction()}`;
let responseText = "";
let messageCount = 0;
let structuredOutput: SpecOutput | null = null;
logger.info("Starting to iterate over stream...");
@@ -114,75 +124,49 @@ ${getAppSpecFormatInstruction()}`;
);
if (msg.type === "assistant") {
// Log the full message structure to debug
logger.info(`Assistant msg keys: ${Object.keys(msg).join(", ")}`);
const msgAny = msg as any;
if (msgAny.message) {
logger.info(
`msg.message keys: ${Object.keys(msgAny.message).join(", ")}`
);
if (msgAny.message.content) {
logger.info(
`msg.message.content length: ${msgAny.message.content.length}`
);
for (const block of msgAny.message.content) {
if (msgAny.message?.content) {
for (const block of msgAny.message.content) {
if (block.type === "text") {
responseText += block.text;
logger.info(
`Block keys: ${Object.keys(block).join(", ")}, type: ${
block.type
}`
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
);
if (block.type === "text") {
responseText += block.text;
logger.info(
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
);
logger.info(`Text preview: ${block.text.substring(0, 200)}...`);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: block.text,
projectPath: projectPath,
});
} else if (block.type === "tool_use") {
logger.info("Tool use:", block.name);
events.emit("spec-regeneration:event", {
type: "spec_tool",
tool: block.name,
input: block.input,
});
}
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: block.text,
projectPath: projectPath,
});
} else if (block.type === "tool_use") {
logger.info("Tool use:", block.name);
events.emit("spec-regeneration:event", {
type: "spec_tool",
tool: block.name,
input: block.input,
});
}
} else {
logger.warn("msg.message.content is falsy");
}
} else {
logger.warn("msg.message is falsy");
// Log full message to see structure
logger.info(
`Full assistant msg: ${JSON.stringify(msg).substring(0, 1000)}`
);
}
} else if (msg.type === "result" && (msg as any).subtype === "success") {
logger.info("Received success result");
logger.info(`Result value: "${(msg as any).result}"`);
logger.info(
`Current responseText length before result: ${responseText.length}`
);
// Only use result if it has content, otherwise keep accumulated text
if ((msg as any).result && (msg as any).result.length > 0) {
logger.info("Using result value as responseText");
responseText = (msg as any).result;
// Check for structured output - this is the reliable way to get spec data
const resultMsg = msg as any;
if (resultMsg.structured_output) {
structuredOutput = resultMsg.structured_output as SpecOutput;
logger.info("✅ Received structured output");
logger.debug("Structured output:", JSON.stringify(structuredOutput, null, 2));
} else {
logger.info("Result is empty, keeping accumulated responseText");
logger.warn("⚠️ No structured output in result, will fall back to text parsing");
}
} else if (msg.type === "result") {
// Handle all result types
// Handle error result types
const subtype = (msg as any).subtype;
logger.info(`Result message: subtype=${subtype}`);
if (subtype === "error_max_turns") {
logger.error(
"❌ Hit max turns limit! Claude used too many tool calls."
);
logger.info(`responseText so far: ${responseText.length} chars`);
logger.error("❌ Hit max turns limit!");
} else if (subtype === "error_max_structured_output_retries") {
logger.error("❌ Failed to produce valid structured output after retries");
throw new Error("Could not produce valid spec output");
}
} else if ((msg as { type: string }).type === "error") {
logger.error("❌ Received error message from stream:");
@@ -202,22 +186,58 @@ ${getAppSpecFormatInstruction()}`;
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
logger.info(`Response text length: ${responseText.length} chars`);
logger.info("========== FINAL RESPONSE TEXT ==========");
logger.info(responseText || "(empty)");
logger.info("========== END RESPONSE TEXT ==========");
if (!responseText || responseText.trim().length === 0) {
logger.error("❌ WARNING: responseText is empty! Nothing to save.");
// Determine XML content to save
let xmlContent: string;
if (structuredOutput) {
// Use structured output - convert JSON to XML
logger.info("✅ Using structured output for XML generation");
xmlContent = specToXml(structuredOutput);
logger.info(`Generated XML from structured output: ${xmlContent.length} chars`);
} else {
// Fallback: Extract XML content from response text
// Claude might include conversational text before/after
// See: https://github.com/AutoMaker-Org/automaker/issues/149
logger.warn("⚠️ No structured output, falling back to text parsing");
logger.info("========== FINAL RESPONSE TEXT ==========");
logger.info(responseText || "(empty)");
logger.info("========== END RESPONSE TEXT ==========");
if (!responseText || responseText.trim().length === 0) {
throw new Error("No response text and no structured output - cannot generate spec");
}
const xmlStart = responseText.indexOf("<project_specification>");
const xmlEnd = responseText.lastIndexOf("</project_specification>");
if (xmlStart !== -1 && xmlEnd !== -1) {
// Extract just the XML content, discarding any conversational text before/after
xmlContent = responseText.substring(xmlStart, xmlEnd + "</project_specification>".length);
logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`);
} else {
// No valid XML structure found in the response text
// This happens when structured output was expected but not received, and the agent
// output conversational text instead of XML (e.g., "The project directory appears to be empty...")
// We should NOT save this conversational text as it's not a valid spec
logger.error("❌ Response does not contain valid <project_specification> XML structure");
logger.error("This typically happens when structured output failed and the agent produced conversational text instead of XML");
throw new Error(
"Failed to generate spec: No valid XML structure found in response. " +
"The response contained conversational text but no <project_specification> tags. " +
"Please try again."
);
}
}
// Save spec to .automaker directory
const specDir = await ensureAutomakerDir(projectPath);
await ensureAutomakerDir(projectPath);
const specPath = getAppSpecPath(projectPath);
logger.info("Saving spec to:", specPath);
logger.info(`Content to save (${responseText.length} chars)`);
logger.info(`Content to save (${xmlContent.length} chars)`);
await fs.writeFile(specPath, responseText);
await fs.writeFile(specPath, xmlContent);
// Verify the file was written
const savedContent = await fs.readFile(specPath, "utf-8");

View File

@@ -29,6 +29,7 @@ const BINARY_EXTENSIONS = new Set([
]);
// Status map for git status codes
// Git porcelain format uses XY where X=staging area, Y=working tree
const GIT_STATUS_MAP: Record<string, string> = {
M: "Modified",
A: "Added",
@@ -37,8 +38,42 @@ const GIT_STATUS_MAP: Record<string, string> = {
C: "Copied",
U: "Updated",
"?": "Untracked",
"!": "Ignored",
" ": "Unmodified",
};
/**
* Get a readable status text from git status codes
* Handles both single character and XY format status codes
*/
function getStatusText(indexStatus: string, workTreeStatus: string): string {
// Untracked files
if (indexStatus === "?" && workTreeStatus === "?") {
return "Untracked";
}
// Ignored files
if (indexStatus === "!" && workTreeStatus === "!") {
return "Ignored";
}
// Prioritize staging area status, then working tree
const primaryStatus = indexStatus !== " " && indexStatus !== "?" ? indexStatus : workTreeStatus;
// Handle combined statuses
if (indexStatus !== " " && indexStatus !== "?" && workTreeStatus !== " " && workTreeStatus !== "?") {
// Both staging and working tree have changes
const indexText = GIT_STATUS_MAP[indexStatus] || "Changed";
const workText = GIT_STATUS_MAP[workTreeStatus] || "Changed";
if (indexText === workText) {
return indexText;
}
return `${indexText} (staged), ${workText} (unstaged)`;
}
return GIT_STATUS_MAP[primaryStatus] || "Changed";
}
/**
* File status interface for git status results
*/
@@ -70,18 +105,46 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
/**
* Parse the output of `git status --porcelain` into FileStatus array
* Git porcelain format: XY PATH where X=staging area status, Y=working tree status
* For renamed files: XY ORIG_PATH -> NEW_PATH
*/
export function parseGitStatus(statusOutput: string): FileStatus[] {
return statusOutput
.split("\n")
.filter(Boolean)
.map((line) => {
const statusChar = line[0];
const filePath = line.slice(3);
// Git porcelain format uses two status characters: XY
// X = status in staging area (index)
// Y = status in working tree
const indexStatus = line[0] || " ";
const workTreeStatus = line[1] || " ";
// File path starts at position 3 (after "XY ")
let filePath = line.slice(3);
// Handle renamed files (format: "R old_path -> new_path")
if (indexStatus === "R" || workTreeStatus === "R") {
const arrowIndex = filePath.indexOf(" -> ");
if (arrowIndex !== -1) {
filePath = filePath.slice(arrowIndex + 4); // Use new path
}
}
// Determine the primary status character for backwards compatibility
// Prioritize staging area status, then working tree
let primaryStatus: string;
if (indexStatus === "?" && workTreeStatus === "?") {
primaryStatus = "?"; // Untracked
} else if (indexStatus !== " " && indexStatus !== "?") {
primaryStatus = indexStatus; // Staged change
} else {
primaryStatus = workTreeStatus; // Working tree change
}
return {
status: statusChar,
status: primaryStatus,
path: filePath,
statusText: GIT_STATUS_MAP[statusChar] || "Unknown",
statusText: getStatusText(indexStatus, workTreeStatus),
};
});
}

View File

@@ -9,7 +9,7 @@ import { getErrorMessage, logError } from "../common.js";
// Optional files that are expected to not exist in new projects
// Don't log ENOENT errors for these to reduce noise
const OPTIONAL_FILES = ["categories.json"];
const OPTIONAL_FILES = ["categories.json", "app_spec.txt"];
function isOptionalFile(filePath: string): boolean {
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));

View File

@@ -9,6 +9,39 @@ import { createSuggestionsOptions } from "../../lib/sdk-options.js";
const logger = createLogger("Suggestions");
/**
* JSON Schema for suggestions output
*/
const suggestionsSchema = {
type: "object",
properties: {
suggestions: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
category: { type: "string" },
description: { type: "string" },
steps: {
type: "array",
items: { type: "string" },
},
priority: {
type: "number",
minimum: 1,
maximum: 3,
},
reasoning: { type: "string" },
},
required: ["category", "description", "steps", "priority", "reasoning"],
},
},
},
required: ["suggestions"],
additionalProperties: false,
};
export async function generateSuggestions(
projectPath: string,
suggestionType: string,
@@ -36,19 +69,7 @@ For each suggestion, provide:
4. Priority (1=high, 2=medium, 3=low)
5. Brief reasoning for why this would help
Format your response as JSON:
{
"suggestions": [
{
"id": "suggestion-123",
"category": "Category",
"description": "What to implement",
"steps": ["Step 1", "Step 2"],
"priority": 1,
"reasoning": "Why this helps"
}
]
}`;
The response will be automatically formatted as structured JSON.`;
events.emit("suggestions:event", {
type: "suggestions_progress",
@@ -58,16 +79,21 @@ Format your response as JSON:
const options = createSuggestionsOptions({
cwd: projectPath,
abortController,
outputFormat: {
type: "json_schema",
schema: suggestionsSchema,
},
});
const stream = query({ prompt, options });
let responseText = "";
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
responseText += block.text;
events.emit("suggestions:event", {
type: "suggestions_progress",
content: block.text,
@@ -81,18 +107,34 @@ Format your response as JSON:
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
// Check for structured output
const resultMsg = msg as any;
if (resultMsg.structured_output) {
structuredOutput = resultMsg.structured_output as {
suggestions: Array<Record<string, unknown>>;
};
logger.debug("Received structured output:", structuredOutput);
}
} else if (msg.type === "result") {
const resultMsg = msg as any;
if (resultMsg.subtype === "error_max_structured_output_retries") {
logger.error("Failed to produce valid structured output after retries");
throw new Error("Could not produce valid suggestions output");
} else if (resultMsg.subtype === "error_max_turns") {
logger.error("Hit max turns limit before completing suggestions generation");
logger.warn(`Response text length: ${responseText.length} chars`);
// Still try to parse what we have
}
}
}
// Parse suggestions from response
// Use structured output if available, otherwise fall back to parsing text
try {
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (structuredOutput && structuredOutput.suggestions) {
// Use structured output directly
events.emit("suggestions:event", {
type: "suggestions_complete",
suggestions: parsed.suggestions.map(
suggestions: structuredOutput.suggestions.map(
(s: Record<string, unknown>, i: number) => ({
...s,
id: s.id || `suggestion-${Date.now()}-${i}`,
@@ -100,7 +142,23 @@ Format your response as JSON:
),
});
} else {
throw new Error("No valid JSON found in response");
// Fallback: try to parse from text (for backwards compatibility)
logger.warn("No structured output received, attempting to parse from text");
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
events.emit("suggestions:event", {
type: "suggestions_complete",
suggestions: parsed.suggestions.map(
(s: Record<string, unknown>, i: number) => ({
...s,
id: s.id || `suggestion-${Date.now()}-${i}`,
})
),
});
} else {
throw new Error("No valid JSON found in response");
}
}
} catch (error) {
// Log the parsing error for debugging

View File

@@ -5,13 +5,17 @@
import { createLogger } from "../../lib/logger.js";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import {
getErrorMessage as getErrorMessageShared,
createLogError,
} from "../common.js";
import { FeatureLoader } from "../../services/feature-loader.js";
const logger = createLogger("Worktree");
const execAsync = promisify(exec);
const featureLoader = new FeatureLoader();
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
"chore: automaker initial commit";

View File

@@ -38,8 +38,10 @@ export function createListBranchesHandler() {
const currentBranch = currentBranchOutput.trim();
// List all local branches
// Use double quotes around the format string for cross-platform compatibility
// Single quotes are preserved literally on Windows; double quotes work on both
const { stdout: branchesOutput } = await execAsync(
"git branch --format='%(refname:short)'",
'git branch --format="%(refname:short)"',
{ cwd: worktreePath }
);
@@ -47,11 +49,15 @@ export function createListBranchesHandler() {
.trim()
.split("\n")
.filter((b) => b.trim())
.map((name) => ({
name: name.trim(),
isCurrent: name.trim() === currentBranch,
isRemote: false,
}));
.map((name) => {
// Remove any surrounding quotes (Windows git may preserve them)
const cleanName = name.trim().replace(/^['"]|['"]$/g, "");
return {
name: cleanName,
isCurrent: cleanName === currentBranch,
isRemote: false,
};
});
// Get ahead/behind count for current branch
let aheadCount = 0;

View File

@@ -23,7 +23,7 @@ import { isAbortError, classifyError } from "../lib/error-handler.js";
import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js";
import type { Feature } from "./feature-loader.js";
import { FeatureLoader } from "./feature-loader.js";
import { getFeatureDir, getAutomakerDir, getFeaturesDir } from "../lib/automaker-paths.js";
import { getFeatureDir, getAutomakerDir, getFeaturesDir, getContextDir } from "../lib/automaker-paths.js";
const execAsync = promisify(exec);
@@ -558,6 +558,9 @@ export class AutoModeService {
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
let prompt: string;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextFiles = await this.loadContextFiles(projectPath);
if (options?.continuationPrompt) {
// Continuation prompt is used when recovering from a plan approval
// The plan was already approved, so skip the planning phase
@@ -591,6 +594,7 @@ export class AutoModeService {
);
// Run the agent with the feature's model and images
// Context files are passed as system prompt for higher priority
await this.runAgent(
workDir,
featureId,
@@ -603,6 +607,7 @@ export class AutoModeService {
projectPath,
planningMode: feature.planningMode,
requirePlanApproval: feature.requirePlanApproval,
systemPrompt: contextFiles || undefined,
}
);
@@ -755,6 +760,9 @@ export class AutoModeService {
// No previous context
}
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextFiles = await this.loadContextFiles(projectPath);
// Build complete prompt with feature info, previous context, and follow-up instructions
let fullPrompt = `## Follow-up on Feature Implementation
@@ -873,6 +881,7 @@ Address the follow-up instructions above. Review the previous work and make the
// Use fullPrompt (already built above) with model and all images
// Note: Follow-ups skip planning mode - they continue from previous work
// Pass previousContext so the history is preserved in the output file
// Context files are passed as system prompt for higher priority
await this.runAgent(
workDir,
featureId,
@@ -885,6 +894,7 @@ Address the follow-up instructions above. Review the previous work and make the
projectPath,
planningMode: 'skip', // Follow-ups don't require approval
previousContent: previousContext || undefined,
systemPrompt: contextFiles || undefined,
}
);
@@ -1083,6 +1093,65 @@ Address the follow-up instructions above. Review the previous work and make the
}
}
/**
* Load context files from .automaker/context/ directory
* These are user-defined context files (CLAUDE.md, CODE_QUALITY.md, etc.)
* that provide project-specific rules and guidelines for the agent.
*/
private async loadContextFiles(projectPath: string): Promise<string> {
// Use path.resolve for cross-platform absolute path handling
const contextDir = path.resolve(getContextDir(projectPath));
try {
// Check if directory exists first
await fs.access(contextDir);
const files = await fs.readdir(contextDir);
// Filter for text-based context files (case-insensitive for Windows)
const textFiles = files.filter((f) => {
const lower = f.toLowerCase();
return lower.endsWith(".md") || lower.endsWith(".txt");
});
if (textFiles.length === 0) return "";
const contents: string[] = [];
for (const file of textFiles) {
// Use path.join for cross-platform path construction
const filePath = path.join(contextDir, file);
const content = await fs.readFile(filePath, "utf-8");
contents.push(`## ${file}\n\n${content}`);
}
console.log(
`[AutoMode] Loaded ${textFiles.length} context file(s): ${textFiles.join(", ")}`
);
return `# ⚠️ CRITICAL: Project Context Files - READ AND FOLLOW STRICTLY
**IMPORTANT**: The following context files contain MANDATORY project-specific rules and conventions. You MUST:
1. Read these rules carefully before taking any action
2. Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`)
3. Follow ALL coding conventions, commit message formats, and architectural patterns specified
4. Reference these rules before running ANY shell commands or making commits
Failure to follow these rules will result in broken builds, failed CI, and rejected commits.
${contents.join("\n\n---\n\n")}
---
**REMINDER**: Before running any command, verify you are using the correct package manager and following the conventions above.
---
`;
} catch {
// Context directory doesn't exist or is empty - this is fine
return "";
}
}
/**
* Analyze project to gather context
*/
@@ -1676,6 +1745,7 @@ This helps parse your summary correctly in the output logs.`;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
previousContent?: string;
systemPrompt?: string;
}
): Promise<void> {
const finalProjectPath = options?.projectPath || projectPath;
@@ -1783,6 +1853,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
false // don't duplicate paths in text
);
// Debug: Log if system prompt is provided
if (options?.systemPrompt) {
console.log(
`[AutoMode] System prompt provided (${options.systemPrompt.length} chars), first 200 chars:\n${options.systemPrompt.substring(0, 200)}...`
);
}
const executeOptions: ExecuteOptions = {
prompt: promptContent,
model: finalModel,
@@ -1790,6 +1867,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
cwd: workDir,
allowedTools: allowedTools,
abortController,
systemPrompt: options?.systemPrompt,
};
// Execute via provider

View File

@@ -1,57 +1,189 @@
import { describe, it, expect } from "vitest";
import {
APP_SPEC_XML_FORMAT,
specToXml,
getStructuredSpecPromptInstruction,
getAppSpecFormatInstruction,
APP_SPEC_XML_FORMAT,
type SpecOutput,
} from "@/lib/app-spec-format.js";
describe("app-spec-format.ts", () => {
describe("APP_SPEC_XML_FORMAT", () => {
it("should export a non-empty string constant", () => {
expect(typeof APP_SPEC_XML_FORMAT).toBe("string");
expect(APP_SPEC_XML_FORMAT.length).toBeGreaterThan(0);
describe("specToXml", () => {
it("should convert minimal spec to XML", () => {
const spec: SpecOutput = {
project_name: "Test Project",
overview: "A test project",
technology_stack: ["TypeScript", "Node.js"],
core_capabilities: ["Testing", "Development"],
implemented_features: [
{ name: "Feature 1", description: "First feature" },
],
};
const xml = specToXml(spec);
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain("<project_specification>");
expect(xml).toContain("</project_specification>");
expect(xml).toContain("<project_name>Test Project</project_name>");
expect(xml).toContain("<technology>TypeScript</technology>");
expect(xml).toContain("<capability>Testing</capability>");
});
it("should contain XML format documentation", () => {
expect(APP_SPEC_XML_FORMAT).toContain("<project_specification>");
expect(APP_SPEC_XML_FORMAT).toContain("</project_specification>");
expect(APP_SPEC_XML_FORMAT).toContain("<project_name>");
expect(APP_SPEC_XML_FORMAT).toContain("<overview>");
expect(APP_SPEC_XML_FORMAT).toContain("<technology_stack>");
expect(APP_SPEC_XML_FORMAT).toContain("<core_capabilities>");
it("should escape XML special characters", () => {
const spec: SpecOutput = {
project_name: "Test & Project",
overview: "Description with <tags>",
technology_stack: ["TypeScript"],
core_capabilities: ["Cap"],
implemented_features: [],
};
const xml = specToXml(spec);
expect(xml).toContain("Test &amp; Project");
expect(xml).toContain("&lt;tags&gt;");
});
it("should contain XML escaping instructions", () => {
expect(APP_SPEC_XML_FORMAT).toContain("&lt;");
expect(APP_SPEC_XML_FORMAT).toContain("&gt;");
expect(APP_SPEC_XML_FORMAT).toContain("&amp;");
it("should include file_locations when provided", () => {
const spec: SpecOutput = {
project_name: "Test",
overview: "Test",
technology_stack: ["TS"],
core_capabilities: ["Cap"],
implemented_features: [
{
name: "Feature",
description: "Desc",
file_locations: ["src/index.ts"],
},
],
};
const xml = specToXml(spec);
expect(xml).toContain("<file_locations>");
expect(xml).toContain("<location>src/index.ts</location>");
});
it("should not include file_locations when empty", () => {
const spec: SpecOutput = {
project_name: "Test",
overview: "Test",
technology_stack: ["TS"],
core_capabilities: ["Cap"],
implemented_features: [
{ name: "Feature", description: "Desc", file_locations: [] },
],
};
const xml = specToXml(spec);
expect(xml).not.toContain("<file_locations>");
});
it("should include additional_requirements when provided", () => {
const spec: SpecOutput = {
project_name: "Test",
overview: "Test",
technology_stack: ["TS"],
core_capabilities: ["Cap"],
implemented_features: [],
additional_requirements: ["Node.js 18+"],
};
const xml = specToXml(spec);
expect(xml).toContain("<additional_requirements>");
expect(xml).toContain("<requirement>Node.js 18+</requirement>");
});
it("should include development_guidelines when provided", () => {
const spec: SpecOutput = {
project_name: "Test",
overview: "Test",
technology_stack: ["TS"],
core_capabilities: ["Cap"],
implemented_features: [],
development_guidelines: ["Use ESLint"],
};
const xml = specToXml(spec);
expect(xml).toContain("<development_guidelines>");
expect(xml).toContain("<guideline>Use ESLint</guideline>");
});
it("should include implementation_roadmap when provided", () => {
const spec: SpecOutput = {
project_name: "Test",
overview: "Test",
technology_stack: ["TS"],
core_capabilities: ["Cap"],
implemented_features: [],
implementation_roadmap: [
{ phase: "Phase 1", status: "completed", description: "Setup" },
],
};
const xml = specToXml(spec);
expect(xml).toContain("<implementation_roadmap>");
expect(xml).toContain("<status>completed</status>");
});
it("should not include optional sections when empty", () => {
const spec: SpecOutput = {
project_name: "Test",
overview: "Test",
technology_stack: ["TS"],
core_capabilities: ["Cap"],
implemented_features: [],
additional_requirements: [],
development_guidelines: [],
implementation_roadmap: [],
};
const xml = specToXml(spec);
expect(xml).not.toContain("<additional_requirements>");
expect(xml).not.toContain("<development_guidelines>");
expect(xml).not.toContain("<implementation_roadmap>");
});
});
describe("getStructuredSpecPromptInstruction", () => {
it("should return non-empty prompt instruction", () => {
const instruction = getStructuredSpecPromptInstruction();
expect(instruction).toBeTruthy();
expect(instruction.length).toBeGreaterThan(100);
});
it("should mention required fields", () => {
const instruction = getStructuredSpecPromptInstruction();
expect(instruction).toContain("project_name");
expect(instruction).toContain("overview");
expect(instruction).toContain("technology_stack");
});
});
describe("getAppSpecFormatInstruction", () => {
it("should return a string containing the XML format", () => {
it("should return non-empty format instruction", () => {
const instruction = getAppSpecFormatInstruction();
expect(typeof instruction).toBe("string");
expect(instruction).toContain(APP_SPEC_XML_FORMAT);
expect(instruction).toBeTruthy();
expect(instruction.length).toBeGreaterThan(100);
});
it("should contain critical formatting requirements", () => {
it("should include critical formatting requirements", () => {
const instruction = getAppSpecFormatInstruction();
expect(instruction).toContain("CRITICAL FORMATTING REQUIREMENTS");
expect(instruction).toContain("<project_specification>");
expect(instruction).toContain("</project_specification>");
});
});
it("should contain verification instructions", () => {
const instruction = getAppSpecFormatInstruction();
expect(instruction).toContain("VERIFICATION");
expect(instruction).toContain("exactly one root XML element");
});
it("should instruct not to use markdown", () => {
const instruction = getAppSpecFormatInstruction();
expect(instruction).toContain("Do NOT use markdown");
expect(instruction).toContain("no # headers");
expect(instruction).toContain("no **bold**");
describe("APP_SPEC_XML_FORMAT", () => {
it("should contain valid XML template structure", () => {
expect(APP_SPEC_XML_FORMAT).toContain("<project_specification>");
expect(APP_SPEC_XML_FORMAT).toContain("</project_specification>");
});
});
});

View File

@@ -16,16 +16,19 @@ import {
} from "@/lib/automaker-paths.js";
describe("automaker-paths.ts", () => {
const projectPath = "/test/project";
const projectPath = path.join("/test", "project");
describe("getAutomakerDir", () => {
it("should return path to .automaker directory", () => {
expect(getAutomakerDir(projectPath)).toBe("/test/project/.automaker");
expect(getAutomakerDir(projectPath)).toBe(
path.join(projectPath, ".automaker")
);
});
it("should handle paths with trailing slashes", () => {
expect(getAutomakerDir("/test/project/")).toBe(
path.join("/test/project/", ".automaker")
const pathWithSlash = path.join("/test", "project") + path.sep;
expect(getAutomakerDir(pathWithSlash)).toBe(
path.join(pathWithSlash, ".automaker")
);
});
});
@@ -33,7 +36,7 @@ describe("automaker-paths.ts", () => {
describe("getFeaturesDir", () => {
it("should return path to features directory", () => {
expect(getFeaturesDir(projectPath)).toBe(
"/test/project/.automaker/features"
path.join(projectPath, ".automaker", "features")
);
});
});
@@ -41,13 +44,13 @@ describe("automaker-paths.ts", () => {
describe("getFeatureDir", () => {
it("should return path to specific feature directory", () => {
expect(getFeatureDir(projectPath, "feature-123")).toBe(
"/test/project/.automaker/features/feature-123"
path.join(projectPath, ".automaker", "features", "feature-123")
);
});
it("should handle feature IDs with special characters", () => {
expect(getFeatureDir(projectPath, "my-feature_v2")).toBe(
"/test/project/.automaker/features/my-feature_v2"
path.join(projectPath, ".automaker", "features", "my-feature_v2")
);
});
});
@@ -55,27 +58,31 @@ describe("automaker-paths.ts", () => {
describe("getFeatureImagesDir", () => {
it("should return path to feature images directory", () => {
expect(getFeatureImagesDir(projectPath, "feature-123")).toBe(
"/test/project/.automaker/features/feature-123/images"
path.join(projectPath, ".automaker", "features", "feature-123", "images")
);
});
});
describe("getBoardDir", () => {
it("should return path to board directory", () => {
expect(getBoardDir(projectPath)).toBe("/test/project/.automaker/board");
expect(getBoardDir(projectPath)).toBe(
path.join(projectPath, ".automaker", "board")
);
});
});
describe("getImagesDir", () => {
it("should return path to images directory", () => {
expect(getImagesDir(projectPath)).toBe("/test/project/.automaker/images");
expect(getImagesDir(projectPath)).toBe(
path.join(projectPath, ".automaker", "images")
);
});
});
describe("getWorktreesDir", () => {
it("should return path to worktrees directory", () => {
expect(getWorktreesDir(projectPath)).toBe(
"/test/project/.automaker/worktrees"
path.join(projectPath, ".automaker", "worktrees")
);
});
});
@@ -83,7 +90,7 @@ describe("automaker-paths.ts", () => {
describe("getAppSpecPath", () => {
it("should return path to app_spec.txt file", () => {
expect(getAppSpecPath(projectPath)).toBe(
"/test/project/.automaker/app_spec.txt"
path.join(projectPath, ".automaker", "app_spec.txt")
);
});
});
@@ -91,7 +98,7 @@ describe("automaker-paths.ts", () => {
describe("getBranchTrackingPath", () => {
it("should return path to active-branches.json file", () => {
expect(getBranchTrackingPath(projectPath)).toBe(
"/test/project/.automaker/active-branches.json"
path.join(projectPath, ".automaker", "active-branches.json")
);
});
});

View File

@@ -40,9 +40,9 @@ describe("sdk-options.ts", () => {
describe("MAX_TURNS", () => {
it("should export turn presets", async () => {
const { MAX_TURNS } = await import("@/lib/sdk-options.js");
expect(MAX_TURNS.quick).toBe(5);
expect(MAX_TURNS.standard).toBe(20);
expect(MAX_TURNS.extended).toBe(50);
expect(MAX_TURNS.quick).toBe(50);
expect(MAX_TURNS.standard).toBe(100);
expect(MAX_TURNS.extended).toBe(250);
expect(MAX_TURNS.maximum).toBe(1000);
});
});
@@ -88,7 +88,7 @@ describe("sdk-options.ts", () => {
expect(options.cwd).toBe("/test/path");
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
expect(options.allowedTools).toEqual([...TOOL_PRESETS.specGeneration]);
expect(options.permissionMode).toBe("acceptEdits");
expect(options.permissionMode).toBe("default");
});
it("should include system prompt when provided", async () => {
@@ -141,7 +141,7 @@ describe("sdk-options.ts", () => {
const options = createSuggestionsOptions({ cwd: "/test/path" });
expect(options.cwd).toBe("/test/path");
expect(options.maxTurns).toBe(MAX_TURNS.quick);
expect(options.maxTurns).toBe(MAX_TURNS.extended);
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
});
});

View File

@@ -13,12 +13,9 @@
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# Vite
/dist/
/dist-electron/
# misc
.DS_Store
@@ -33,12 +30,8 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Playwright
/test-results/
@@ -47,5 +40,8 @@ next-env.d.ts
/playwright/.cache/
# Electron
/dist/
/release/
/server-bundle/
# TanStack Router generated
src/routeTree.gen.ts

36
apps/ui/eslint.config.mjs Normal file
View File

@@ -0,0 +1,36 @@
import { defineConfig, globalIgnores } from "eslint/config";
import js from "@eslint/js";
import ts from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
const eslintConfig = defineConfig([
js.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
plugins: {
"@typescript-eslint": ts,
},
rules: {
...ts.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
},
},
globalIgnores([
"dist/**",
"dist-electron/**",
"node_modules/**",
"server-bundle/**",
"release/**",
"src/routeTree.gen.ts",
]),
]);
export default eslintConfig;

32
apps/ui/index.html Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en" suppressHydrationWarning>
<head>
<meta charset="UTF-8" />
<title>Automaker - Autonomous AI Development Studio</title>
<meta name="description" content="Build software autonomously with AI agents" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script>
// Prevent theme flash - apply stored theme before React hydrates
(function() {
try {
const stored = localStorage.getItem('automaker-storage');
if (stored) {
const data = JSON.parse(stored);
const theme = data.state?.theme;
if (theme && theme !== 'system' && theme !== 'light') {
// Apply the actual theme class (dark, retro, dracula, nord, etc.)
document.documentElement.classList.add(theme);
} else if (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
}
} catch (e) {}
})();
</script>
</head>
<body class="antialiased">
<div id="app"></div>
<script type="module" src="/src/renderer.tsx"></script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
{
"name": "@automaker/app",
"name": "@automaker/ui",
"version": "0.1.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
@@ -13,25 +13,29 @@
},
"private": true,
"license": "Unlicense",
"main": "electron/main.js",
"main": "dist-electron/main.js",
"scripts": {
"dev": "next dev -p 3007",
"dev:web": "next dev -p 3007",
"dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
"dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"",
"build": "next build",
"build:electron": "node scripts/prepare-server.js && next build && electron-builder",
"build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
"build:electron:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
"dev": "vite",
"dev:web": "cross-env VITE_SKIP_ELECTRON=true vite",
"dev:electron": "vite",
"dev:electron:debug": "cross-env OPEN_DEVTOOLS=true vite",
"build": "vite build",
"build:electron": "node scripts/prepare-server.mjs && vite build && electron-builder",
"build:electron:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --dir",
"build:electron:win": "node scripts/prepare-server.mjs && vite build && electron-builder --win",
"build:electron:win:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --win --dir",
"build:electron:mac": "node scripts/prepare-server.mjs && vite build && electron-builder --mac",
"build:electron:mac:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --mac --dir",
"build:electron:linux": "node scripts/prepare-server.mjs && vite build && electron-builder --linux",
"build:electron:linux:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --linux --dir",
"postinstall": "electron-builder install-app-deps",
"start": "next start",
"preview": "vite preview",
"lint": "eslint",
"pretest": "node scripts/setup-e2e-fixtures.js",
"pretest": "node scripts/setup-e2e-fixtures.mjs",
"test": "playwright test",
"test:headed": "playwright test --headed",
"dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"",
"dev:electron:wsl:gpu": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA electron . --no-sandbox --disable-gpu-sandbox\""
"dev:electron:wsl": "cross-env vite",
"dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite"
},
"dependencies": {
"@codemirror/lang-xml": "^6.1.0",
@@ -53,6 +57,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
@@ -62,10 +67,9 @@
"cmdk": "^1.1.1",
"dotenv": "^17.2.3",
"geist": "^1.5.1",
"lucide-react": "^0.556.0",
"next": "^16.0.10",
"react": "19.2.0",
"react-dom": "19.2.0",
"lucide-react": "^0.562.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"sonner": "^2.0.7",
@@ -85,32 +89,39 @@
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@eslint/js": "^9.0.0",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.2.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.141.7",
"@types/node": "^22",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"@vitejs/plugin-react": "^5.1.2",
"cross-env": "^10.1.0",
"electron": "39.2.7",
"electron-builder": "^26.0.12",
"eslint": "^9",
"eslint-config-next": "16.0.7",
"tailwindcss": "^4",
"eslint": "^9.39.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3",
"wait-on": "^9.0.3"
"vite": "^7.3.0",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6"
},
"build": {
"appId": "com.automaker.app",
"productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}",
"afterPack": "./scripts/rebuild-server-natives.js",
"npmRebuild": false,
"afterPack": "./scripts/rebuild-server-natives.cjs",
"directories": {
"output": "dist"
"output": "release"
},
"files": [
"electron/**/*",
"out/**/*",
"dist/**/*",
"dist-electron/**/*",
"public/**/*",
"!node_modules/**/*"
],

View File

@@ -44,15 +44,17 @@ export default defineConfig({
ALLOWED_PROJECT_DIRS: "/Users,/home,/tmp,/var/folders",
},
},
// Frontend Next.js server
// Frontend Vite dev server
{
command: `npx next dev -p ${port}`,
command: `npm run dev`,
url: `http://localhost:${port}`,
reuseExistingServer: true,
timeout: 120000,
env: {
...process.env,
NEXT_PUBLIC_SKIP_SETUP: "true",
VITE_SKIP_SETUP: "true",
// Skip electron plugin in CI - no display available for Electron
VITE_SKIP_ELECTRON: process.env.CI === "true" ? "true" : undefined,
},
},
],

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 317 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

View File

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -12,7 +12,7 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve workspace root (apps/app/scripts -> workspace root)
// Resolve workspace root (apps/ui/scripts -> workspace root)
const WORKSPACE_ROOT = path.resolve(__dirname, "../../..");
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");

7
apps/ui/src/App.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./utils/router";
import "./styles/global.css";
export default function App() {
return <RouterProvider router={router} />;
}

View File

@@ -1,4 +1,3 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react";
@@ -72,7 +71,7 @@ export function BoardBackgroundModal({
useEffect(() => {
if (currentProject && backgroundSettings.imagePath) {
const serverUrl =
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
// Add cache-busting query parameter to force browser to reload image
const cacheBuster = imageVersion
? `&v=${imageVersion}`

View File

@@ -1,4 +1,3 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
@@ -129,7 +128,7 @@ export function FileBrowserDialog({
try {
// Get server URL from environment or default
const serverUrl =
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: "POST",

View File

@@ -1,9 +1,7 @@
"use client";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { useNavigate, useLocation } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store";
import { CoursePromoBadge } from "@/components/ui/course-promo-badge";
import {
FolderOpen,
Plus,
@@ -82,10 +80,8 @@ import { themeOptions } from "@/config/theme-options";
import type { SpecRegenerationEvent } from "@/types/electron";
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
import { NewProjectModal } from "@/components/new-project-modal";
import {
ProjectSetupDialog,
type FeatureCount,
} from "@/components/layout/project-setup-dialog";
import { CreateSpecDialog } from "@/components/views/spec-view/dialogs";
import type { FeatureCount } from "@/components/views/spec-view/types";
import {
DndContext,
DragEndEvent,
@@ -223,16 +219,17 @@ const BugReportButton = ({
};
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const {
projects,
trashedProjects,
currentProject,
currentView,
sidebarOpen,
projectHistory,
upsertAndSetCurrentProject,
setCurrentProject,
setCurrentView,
toggleSidebar,
restoreTrashedProject,
deleteTrashedProject,
@@ -251,14 +248,13 @@ export function Sidebar() {
} = useAppStore();
// Environment variable flags for hiding sidebar items
// Note: Next.js requires static access to process.env variables (no dynamic keys)
const hideTerminal = process.env.NEXT_PUBLIC_HIDE_TERMINAL === "true";
const hideWiki = process.env.NEXT_PUBLIC_HIDE_WIKI === "true";
const hideTerminal = import.meta.env.VITE_HIDE_TERMINAL === "true";
const hideWiki = import.meta.env.VITE_HIDE_WIKI === "true";
const hideRunningAgents =
process.env.NEXT_PUBLIC_HIDE_RUNNING_AGENTS === "true";
const hideContext = process.env.NEXT_PUBLIC_HIDE_CONTEXT === "true";
const hideSpecEditor = process.env.NEXT_PUBLIC_HIDE_SPEC_EDITOR === "true";
const hideAiProfiles = process.env.NEXT_PUBLIC_HIDE_AI_PROFILES === "true";
import.meta.env.VITE_HIDE_RUNNING_AGENTS === "true";
const hideContext = import.meta.env.VITE_HIDE_CONTEXT === "true";
const hideSpecEditor = import.meta.env.VITE_HIDE_SPEC_EDITOR === "true";
const hideAiProfiles = import.meta.env.VITE_HIDE_AI_PROFILES === "true";
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
@@ -291,6 +287,7 @@ export function Sidebar() {
const [setupProjectPath, setSetupProjectPath] = useState("");
const [projectOverview, setProjectOverview] = useState("");
const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProject, setAnalyzeProject] = useState(true);
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
@@ -429,7 +426,6 @@ export function Sidebar() {
unsubscribe();
};
}, [
setCurrentView,
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
@@ -498,7 +494,7 @@ export function Sidebar() {
setupProjectPath,
projectOverview.trim(),
generateFeatures,
undefined, // analyzeProject - use default
analyzeProject,
generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features
);
@@ -527,6 +523,7 @@ export function Sidebar() {
setupProjectPath,
projectOverview,
generateFeatures,
analyzeProject,
featureCount,
setSpecCreatingForProject,
]);
@@ -1177,7 +1174,7 @@ export function Sidebar() {
if (item.shortcut) {
shortcutsList.push({
key: item.shortcut,
action: () => setCurrentView(item.id as any),
action: () => navigate({ to: `/${item.id}` as const }),
description: `Navigate to ${item.label}`,
});
}
@@ -1187,7 +1184,7 @@ export function Sidebar() {
// Add settings shortcut
shortcutsList.push({
key: shortcuts.settings,
action: () => setCurrentView("settings"),
action: () => navigate({ to: "/settings" }),
description: "Navigate to Settings",
});
}
@@ -1196,7 +1193,7 @@ export function Sidebar() {
}, [
shortcuts,
currentProject,
setCurrentView,
navigate,
toggleSidebar,
projects.length,
handleOpenFolder,
@@ -1210,7 +1207,9 @@ export function Sidebar() {
useKeyboardShortcuts(navigationShortcuts);
const isActiveRoute = (id: string) => {
return currentView === id;
// Map view IDs to route paths
const routePath = id === "welcome" ? "/" : `/${id}`;
return location.pathname === routePath;
};
return (
@@ -1289,7 +1288,7 @@ export function Sidebar() {
"flex items-center gap-3 titlebar-no-drag cursor-pointer group",
!sidebarOpen && "flex-col gap-1"
)}
onClick={() => setCurrentView("welcome")}
onClick={() => navigate({ to: "/" })}
data-testid="logo-button"
>
{!sidebarOpen ? (
@@ -1847,7 +1846,7 @@ export function Sidebar() {
return (
<button
key={item.id}
onClick={() => setCurrentView(item.id as any)}
onClick={() => navigate({ to: `/${item.id}` as const })}
className={cn(
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
"transition-all duration-200 ease-out",
@@ -1872,9 +1871,6 @@ export function Sidebar() {
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
{isActive && (
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full shadow-sm shadow-brand-500/50"></div>
)}
<Icon
className={cn(
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
@@ -1945,13 +1941,11 @@ export function Sidebar() {
"bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent"
)}
>
{/* Course Promo Badge */}
<CoursePromoBadge sidebarOpen={sidebarOpen} />
{/* Wiki Link */}
{!hideWiki && (
<div className="p-2 pb-0">
<button
onClick={() => setCurrentView("wiki")}
onClick={() => navigate({ to: "/wiki" })}
className={cn(
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
"transition-all duration-200 ease-out",
@@ -1974,9 +1968,6 @@ export function Sidebar() {
title={!sidebarOpen ? "Wiki" : undefined}
data-testid="wiki-link"
>
{isActiveRoute("wiki") && (
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full shadow-sm shadow-brand-500/50"></div>
)}
<BookOpen
className={cn(
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
@@ -2014,7 +2005,7 @@ export function Sidebar() {
{!hideRunningAgents && (
<div className="p-2 pb-0">
<button
onClick={() => setCurrentView("running-agents")}
onClick={() => navigate({ to: "/running-agents" })}
className={cn(
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
"transition-all duration-200 ease-out",
@@ -2037,9 +2028,6 @@ export function Sidebar() {
title={!sidebarOpen ? "Running Agents" : undefined}
data-testid="running-agents-link"
>
{isActiveRoute("running-agents") && (
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full shadow-sm shadow-brand-500/50"></div>
)}
<div className="relative">
<Activity
className={cn(
@@ -2112,7 +2100,7 @@ export function Sidebar() {
{/* Settings Link */}
<div className="p-2">
<button
onClick={() => setCurrentView("settings")}
onClick={() => navigate({ to: "/settings" })}
className={cn(
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
"transition-all duration-200 ease-out",
@@ -2135,9 +2123,6 @@ export function Sidebar() {
title={!sidebarOpen ? "Settings" : undefined}
data-testid="settings-button"
>
{isActiveRoute("settings") && (
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full shadow-sm shadow-brand-500/50"></div>
)}
<Settings
className={cn(
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
@@ -2276,18 +2261,23 @@ export function Sidebar() {
</Dialog>
{/* New Project Setup Dialog */}
<ProjectSetupDialog
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
{/* New Project Onboarding Dialog */}

View File

@@ -1,4 +1,3 @@
"use client";
import { useState, useEffect } from "react";
import {

View File

@@ -1,4 +1,3 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import { GitBranch } from "lucide-react";

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import { Autocomplete } from "@/components/ui/autocomplete";

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";

View File

@@ -1,4 +1,3 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"

View File

@@ -1,4 +1,3 @@
"use client";
import { useState, useEffect } from "react";
import { Clock } from "lucide-react";

View File

@@ -1,4 +1,3 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
@@ -85,7 +84,7 @@ export function DescriptionImageDropZone({
// Construct server URL for loading saved images
const getImageServerUrl = useCallback((imagePath: string): string => {
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
const projectPath = currentProject?.path || "";
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
}, [currentProject?.path]);

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";

View File

@@ -1,4 +1,3 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"

View File

@@ -1,4 +1,3 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,4 +1,3 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import { useEffect, useCallback, useRef } from "react";

View File

@@ -1,4 +1,3 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";

View File

@@ -1,4 +1,3 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"

View File

@@ -1,4 +1,3 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import {
@@ -326,7 +325,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
return (
<div
className={cn(
"rounded-lg border-l-4 transition-all duration-200",
"rounded-lg border transition-all duration-200",
bgColor,
borderColor,
"hover:brightness-110"
@@ -380,7 +379,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
{formattedContent.map((part, index) => (
<div key={index}>
{part.type === "json" ? (
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto scrollbar-styled text-xs text-primary">
{part.content}
</pre>
) : (
@@ -419,6 +418,8 @@ export function LogViewer({ output, className }: LogViewerProps) {
const [searchQuery, setSearchQuery] = useState("");
const [hiddenTypes, setHiddenTypes] = useState<Set<LogEntryType>>(new Set());
const [hiddenCategories, setHiddenCategories] = useState<Set<ToolCategory>>(new Set());
// Track if user has "Expand All" mode active - new entries will auto-expand when this is true
const [expandAllMode, setExpandAllMode] = useState(false);
// Parse entries and compute initial expanded state together
const { entries, initialExpandedIds } = useMemo(() => {
@@ -443,16 +444,27 @@ export function LogViewer({ output, className }: LogViewerProps) {
const appliedInitialRef = useRef<Set<string>>(new Set());
// Apply initial expanded state for new entries
// Also auto-expand all entries when expandAllMode is active
const effectiveExpandedIds = useMemo(() => {
const result = new Set(expandedIds);
initialExpandedIds.forEach((id) => {
if (!appliedInitialRef.current.has(id)) {
appliedInitialRef.current.add(id);
result.add(id);
}
});
// If expand all mode is active, expand all filtered entries
if (expandAllMode) {
entries.forEach((entry) => {
result.add(entry.id);
});
} else {
// Otherwise, only auto-expand entries based on initial state (shouldCollapseByDefault)
initialExpandedIds.forEach((id) => {
if (!appliedInitialRef.current.has(id)) {
appliedInitialRef.current.add(id);
result.add(id);
}
});
}
return result;
}, [expandedIds, initialExpandedIds]);
}, [expandedIds, initialExpandedIds, expandAllMode, entries]);
// Calculate stats for tool categories
const stats = useMemo(() => {
@@ -508,6 +520,10 @@ export function LogViewer({ output, className }: LogViewerProps) {
}, [entries, hiddenTypes, hiddenCategories, searchQuery]);
const toggleEntry = (id: string) => {
// When user manually collapses an entry, turn off expand all mode
if (effectiveExpandedIds.has(id)) {
setExpandAllMode(false);
}
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
@@ -520,10 +536,14 @@ export function LogViewer({ output, className }: LogViewerProps) {
};
const expandAll = () => {
// Enable expand all mode so new entries will also be expanded
setExpandAllMode(true);
setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
};
const collapseAll = () => {
// Disable expand all mode when collapsing all
setExpandAllMode(false);
setExpandedIds(new Set());
};
@@ -566,7 +586,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No log entries yet. Logs will appear here as the process runs.</p>
{output && output.trim() && (
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto">
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto scrollbar-styled">
<pre className="whitespace-pre-wrap">{output}</pre>
</div>
)}
@@ -700,10 +720,16 @@ export function LogViewer({ output, className }: LogViewerProps) {
</span>
<button
onClick={expandAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
className={cn(
"text-xs px-2 py-1 rounded transition-colors",
expandAllMode
? "text-primary bg-primary/20 hover:bg-primary/30"
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
)}
data-testid="log-expand-all"
title={expandAllMode ? "Expand All (Active - new items will auto-expand)" : "Expand All"}
>
Expand All
Expand All{expandAllMode ? " (On)" : ""}
</button>
<button
onClick={collapseAll}

View File

@@ -1,4 +1,3 @@
"use client";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";

View File

@@ -1,4 +1,3 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";

View File

@@ -1,4 +1,3 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"

View File

@@ -1,4 +1,3 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"

View File

@@ -1,4 +1,3 @@
"use client";
import CodeMirror from "@uiw/react-codemirror";
import { xml } from "@codemirror/lang-xml";

View File

@@ -1,4 +1,3 @@
"use client";
import { useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";

View File

@@ -1,4 +1,3 @@
"use client";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useAppStore, type AgentModel } from "@/store/app-store";
@@ -756,8 +755,8 @@ export function AgentView() {
/>
)}
{/* Selected Images Preview */}
{selectedImages.length > 0 && (
{/* Selected Images Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{selectedImages.length > 0 && !showImageDropZone && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">

View File

@@ -1,4 +1,3 @@
"use client";
import { useCallback, useState } from "react";
import {

View File

@@ -1,4 +1,3 @@
"use client";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import {

View File

@@ -1,4 +1,3 @@
"use client";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";

View File

@@ -1,4 +1,3 @@
"use client";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";

View File

@@ -1,4 +1,3 @@
"use client";
import { useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";

View File

@@ -1,4 +1,3 @@
"use client";
import { useState, useEffect, useMemo, memo } from "react";
import { useSortable } from "@dnd-kit/sortable";

Some files were not shown because too many files have changed in this diff Show More