Merge main into massive-terminal-upgrade

Resolves merge conflicts:
- apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger
- apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions
- apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling)
- apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes
- apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuperComboGamer
2025-12-21 20:27:44 -05:00
393 changed files with 32473 additions and 17974 deletions

View File

@@ -1,221 +0,0 @@
/**
* Dependency Resolution Utility
*
* Provides topological sorting and dependency analysis for features.
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
*/
import type { Feature } from "@/store/app-store";
export interface DependencyResolutionResult {
orderedFeatures: Feature[]; // Features in dependency-aware order
circularDependencies: string[][]; // Groups of IDs forming cycles
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
}
/**
* Resolves feature dependencies using topological sort with priority-aware ordering.
*
* Algorithm:
* 1. Build dependency graph and detect missing/blocked dependencies
* 2. Apply Kahn's algorithm for topological sort
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
* 4. Detect circular dependencies for features that can't be ordered
*
* @param features - Array of features to order
* @returns Resolution result with ordered features and dependency metadata
*/
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
const inDegree = new Map<string, number>();
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
const missingDependencies = new Map<string, string[]>();
const blockedFeatures = new Map<string, string[]>();
// Initialize graph structures
for (const feature of features) {
inDegree.set(feature.id, 0);
adjacencyList.set(feature.id, []);
}
// Build dependency graph and detect missing/blocked dependencies
for (const feature of features) {
const deps = feature.dependencies || [];
for (const depId of deps) {
if (!featureMap.has(depId)) {
// Missing dependency - track it
if (!missingDependencies.has(feature.id)) {
missingDependencies.set(feature.id, []);
}
missingDependencies.get(feature.id)!.push(depId);
} else {
// Valid dependency - add edge to graph
adjacencyList.get(depId)!.push(feature.id);
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
// Check if dependency is incomplete (blocking)
const depFeature = featureMap.get(depId)!;
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
if (!blockedFeatures.has(feature.id)) {
blockedFeatures.set(feature.id, []);
}
blockedFeatures.get(feature.id)!.push(depId);
}
}
}
}
// Kahn's algorithm with priority-aware selection
const queue: Feature[] = [];
const orderedFeatures: Feature[] = [];
// Helper to sort features by priority (lower number = higher priority)
const sortByPriority = (a: Feature, b: Feature) =>
(a.priority ?? 2) - (b.priority ?? 2);
// Start with features that have no dependencies (in-degree 0)
for (const [id, degree] of inDegree) {
if (degree === 0) {
queue.push(featureMap.get(id)!);
}
}
// Sort initial queue by priority
queue.sort(sortByPriority);
// Process features in topological order
while (queue.length > 0) {
// Take highest priority feature from queue
const current = queue.shift()!;
orderedFeatures.push(current);
// Process features that depend on this one
for (const dependentId of adjacencyList.get(current.id) || []) {
const currentDegree = inDegree.get(dependentId);
if (currentDegree === undefined) {
throw new Error(`In-degree not initialized for feature ${dependentId}`);
}
const newDegree = currentDegree - 1;
inDegree.set(dependentId, newDegree);
if (newDegree === 0) {
queue.push(featureMap.get(dependentId)!);
// Re-sort queue to maintain priority order
queue.sort(sortByPriority);
}
}
}
// Detect circular dependencies (features not in output = part of cycle)
const circularDependencies: string[][] = [];
const processedIds = new Set(orderedFeatures.map(f => f.id));
if (orderedFeatures.length < features.length) {
// Find cycles using DFS
const remaining = features.filter(f => !processedIds.has(f.id));
const cycles = detectCycles(remaining, featureMap);
circularDependencies.push(...cycles);
// Add remaining features at end (part of cycles)
orderedFeatures.push(...remaining);
}
return {
orderedFeatures,
circularDependencies,
missingDependencies,
blockedFeatures
};
}
/**
* Detects circular dependencies using depth-first search
*
* @param features - Features that couldn't be topologically sorted (potential cycles)
* @param featureMap - Map of all features by ID
* @returns Array of cycles, where each cycle is an array of feature IDs
*/
function detectCycles(
features: Feature[],
featureMap: Map<string, Feature>
): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
const currentPath: string[] = [];
function dfs(featureId: string): boolean {
visited.add(featureId);
recursionStack.add(featureId);
currentPath.push(featureId);
const feature = featureMap.get(featureId);
if (feature) {
for (const depId of feature.dependencies || []) {
if (!visited.has(depId)) {
if (dfs(depId)) return true;
} else if (recursionStack.has(depId)) {
// Found cycle - extract it
const cycleStart = currentPath.indexOf(depId);
cycles.push(currentPath.slice(cycleStart));
return true;
}
}
}
currentPath.pop();
recursionStack.delete(featureId);
return false;
}
for (const feature of features) {
if (!visited.has(feature.id)) {
dfs(feature.id);
}
}
return cycles;
}
/**
* Checks if a feature's dependencies are satisfied (all complete or verified)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns true if all dependencies are satisfied, false otherwise
*/
export function areDependenciesSatisfied(
feature: Feature,
allFeatures: Feature[]
): boolean {
if (!feature.dependencies || feature.dependencies.length === 0) {
return true; // No dependencies = always ready
}
return feature.dependencies.every(depId => {
const dep = allFeatures.find(f => f.id === depId);
return dep && (dep.status === 'completed' || dep.status === 'verified');
});
}
/**
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns Array of feature IDs that are blocking this feature
*/
export function getBlockingDependencies(
feature: Feature,
allFeatures: Feature[]
): string[] {
if (!feature.dependencies || feature.dependencies.length === 0) {
return [];
}
return feature.dependencies.filter(depId => {
const dep = allFeatures.find(f => f.id === depId);
return dep && dep.status !== 'completed' && dep.status !== 'verified';
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
/**
* File Picker Utility for Web Browsers
*
*
* Provides cross-platform file and directory selection using:
* 1. HTML5 webkitdirectory input - primary method (works on Windows)
* 2. File System Access API (showDirectoryPicker) - fallback for modern browsers
*
*
* Note: Browsers don't expose absolute file paths for security reasons.
* This implementation extracts directory information and may require
* user confirmation or server-side path resolution.
@@ -22,7 +22,7 @@ export interface DirectoryPickerResult {
/**
* Opens a directory picker dialog
* @returns Promise resolving to directory information, or null if canceled
*
*
* Note: Browsers don't expose absolute file paths for security reasons.
* This function returns directory structure information that the server
* can use to locate the actual directory path.
@@ -31,10 +31,10 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
// Use webkitdirectory (works on Windows and all modern browsers)
return new Promise<DirectoryPickerResult | null>((resolve) => {
let resolved = false;
const input = document.createElement("input");
input.type = "file";
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true;
input.style.display = "none";
input.style.display = 'none';
const cleanup = () => {
if (input.parentNode) {
@@ -58,62 +58,59 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
}
};
input.addEventListener("change", (e) => {
input.addEventListener('change', (e) => {
changeEventFired = true;
if (focusTimeout) {
clearTimeout(focusTimeout);
focusTimeout = null;
}
console.log("[FilePicker] Change event fired");
console.log('[FilePicker] Change event fired');
const files = input.files;
console.log("[FilePicker] Files selected:", files?.length || 0);
console.log('[FilePicker] Files selected:', files?.length || 0);
if (!files || files.length === 0) {
console.log("[FilePicker] No files selected");
console.log('[FilePicker] No files selected');
safeResolve(null);
return;
}
const firstFile = files[0];
console.log("[FilePicker] First file:", {
console.log('[FilePicker] First file:', {
name: firstFile.name,
webkitRelativePath: firstFile.webkitRelativePath,
// @ts-expect-error
// @ts-expect-error - path property is non-standard but available in some browsers
path: firstFile.path,
});
// Extract directory name from webkitRelativePath
// webkitRelativePath format: "directoryName/subfolder/file.txt" or "directoryName/file.txt"
let directoryName = "Selected Directory";
let directoryName = 'Selected Directory';
// Method 1: Try to get absolute path from File object (non-standard, works in Electron/Chromium)
// @ts-expect-error - path property is non-standard but available in some browsers
if (firstFile.path) {
// @ts-expect-error
// @ts-expect-error - path property is non-standard but available in some browsers
const filePath = firstFile.path as string;
console.log("[FilePicker] Found file.path:", filePath);
console.log('[FilePicker] Found file.path:', filePath);
// Extract directory path (remove filename)
const lastSeparator = Math.max(
filePath.lastIndexOf("\\"),
filePath.lastIndexOf("/")
);
const lastSeparator = Math.max(filePath.lastIndexOf('\\'), filePath.lastIndexOf('/'));
if (lastSeparator > 0) {
const absolutePath = filePath.substring(0, lastSeparator);
console.log("[FilePicker] Found absolute path:", absolutePath);
console.log('[FilePicker] Found absolute path:', absolutePath);
// Return as directory name for now - server can validate it directly
directoryName = absolutePath;
}
}
// Method 2: Extract directory name from webkitRelativePath
if (directoryName === "Selected Directory" && firstFile.webkitRelativePath) {
if (directoryName === 'Selected Directory' && firstFile.webkitRelativePath) {
const relativePath = firstFile.webkitRelativePath;
console.log("[FilePicker] Using webkitRelativePath:", relativePath);
const pathParts = relativePath.split("/");
console.log('[FilePicker] Using webkitRelativePath:', relativePath);
const pathParts = relativePath.split('/');
if (pathParts.length > 0) {
directoryName = pathParts[0]; // Top-level directory name
console.log("[FilePicker] Extracted directory name:", directoryName);
console.log('[FilePicker] Extracted directory name:', directoryName);
}
}
@@ -130,7 +127,7 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
}
}
console.log("[FilePicker] Directory info:", {
console.log('[FilePicker] Directory info:', {
directoryName,
fileCount: files.length,
sampleFiles: sampleFiles.slice(0, 5), // Log first 5
@@ -150,7 +147,7 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
// Only resolve as canceled if change event hasn't fired after a delay
focusTimeout = setTimeout(() => {
if (!resolved && !changeEventFired && (!input.files || input.files.length === 0)) {
console.log("[FilePicker] Dialog canceled (no files after focus and no change event)");
console.log('[FilePicker] Dialog canceled (no files after focus and no change event)');
safeResolve(null);
}
}, 2000); // Increased timeout for Windows - give it time
@@ -158,33 +155,37 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
// Add to DOM temporarily
document.body.appendChild(input);
console.log("[FilePicker] Opening directory picker...");
console.log('[FilePicker] Opening directory picker...');
// Try to show picker programmatically
if ("showPicker" in HTMLInputElement.prototype) {
if ('showPicker' in HTMLInputElement.prototype) {
try {
(input as any).showPicker();
console.log("[FilePicker] Using showPicker()");
console.log('[FilePicker] Using showPicker()');
} catch (error) {
console.log("[FilePicker] showPicker() failed, using click()", error);
console.log('[FilePicker] showPicker() failed, using click()', error);
input.click();
}
} else {
console.log("[FilePicker] Using click()");
console.log('[FilePicker] Using click()');
input.click();
}
// Set up cancellation detection with longer delay
// Only add focus listener if we're not already resolved
window.addEventListener("focus", handleFocus, { once: true });
window.addEventListener('focus', handleFocus, { once: true });
// Also handle blur as a cancellation signal (but with delay)
window.addEventListener("blur", () => {
// Dialog opened, wait for it to close
setTimeout(() => {
window.addEventListener("focus", handleFocus, { once: true });
}, 100);
}, { once: true });
window.addEventListener(
'blur',
() => {
// Dialog opened, wait for it to close
setTimeout(() => {
window.addEventListener('focus', handleFocus, { once: true });
}, 100);
},
{ once: true }
);
});
}
@@ -193,21 +194,19 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
* @param options Optional configuration (multiple files, file types, etc.)
* @returns Promise resolving to selected file path(s), or null if canceled
*/
export async function openFilePicker(
options?: {
multiple?: boolean;
accept?: string;
}
): Promise<string | string[] | null> {
export async function openFilePicker(options?: {
multiple?: boolean;
accept?: string;
}): Promise<string | string[] | null> {
// Use standard file input (works on all browsers including Windows)
return new Promise<string | string[] | null>((resolve) => {
const input = document.createElement("input");
input.type = "file";
const input = document.createElement('input');
input.type = 'file';
input.multiple = options?.multiple ?? false;
if (options?.accept) {
input.accept = options.accept;
}
input.style.display = "none";
input.style.display = 'none';
const cleanup = () => {
if (input.parentNode) {
@@ -215,7 +214,7 @@ export async function openFilePicker(
}
};
input.addEventListener("change", () => {
input.addEventListener('change', () => {
const files = input.files;
if (!files || files.length === 0) {
cleanup();
@@ -228,7 +227,7 @@ export async function openFilePicker(
// Try to get path from File object (non-standard, but available in some browsers)
// @ts-expect-error - path property is non-standard
if (file.path) {
// @ts-expect-error
// @ts-expect-error - path property is non-standard but available in some browsers
return file.path as string;
}
// Fallback to filename (server will need to resolve)
@@ -262,7 +261,7 @@ export async function openFilePicker(
// Try to show picker programmatically
// Note: showPicker() is available in modern browsers but TypeScript types it as void
// In practice, it may return a Promise in some implementations, but we'll handle errors via try/catch
if ("showPicker" in HTMLInputElement.prototype) {
if ('showPicker' in HTMLInputElement.prototype) {
try {
(input as any).showPicker();
} catch {
@@ -274,6 +273,6 @@ export async function openFilePicker(
}
// Set up cancellation detection
window.addEventListener("focus", handleFocus, { once: true });
window.addEventListener('focus', handleFocus, { once: true });
});
}

View File

@@ -20,41 +20,35 @@ import type {
AutoModeEvent,
SuggestionsEvent,
SpecRegenerationEvent,
FeatureSuggestion,
SuggestionType,
} from "./electron";
import type { Message, SessionListItem } from "@/types/electron";
import type { Feature } from "@/store/app-store";
import type {
WorktreeAPI,
GitAPI,
ModelDefinition,
ProviderStatus,
} from "@/types/electron";
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
} from './electron';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
// Server URL - configurable via environment variable
const getServerUrl = (): string => {
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
}
return "http://localhost:3008";
return 'http://localhost:3008';
};
// Get API key from environment variable
const getApiKey = (): string | null => {
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
}
return null;
};
type EventType =
| "agent:stream"
| "auto-mode:event"
| "suggestions:event"
| "spec-regeneration:event";
| 'agent:stream'
| 'auto-mode:event'
| 'suggestions:event'
| 'spec-regeneration:event';
type EventCallback = (payload: unknown) => void;
@@ -80,21 +74,18 @@ export class HttpApiClient implements ElectronAPI {
}
private connectWebSocket(): void {
if (
this.isConnecting ||
(this.ws && this.ws.readyState === WebSocket.OPEN)
) {
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
return;
}
this.isConnecting = true;
try {
const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events";
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log("[HttpApiClient] WebSocket connected");
console.log('[HttpApiClient] WebSocket connected');
this.isConnecting = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
@@ -110,15 +101,12 @@ export class HttpApiClient implements ElectronAPI {
callbacks.forEach((cb) => cb(data.payload));
}
} catch (error) {
console.error(
"[HttpApiClient] Failed to parse WebSocket message:",
error
);
console.error('[HttpApiClient] Failed to parse WebSocket message:', error);
}
};
this.ws.onclose = () => {
console.log("[HttpApiClient] WebSocket disconnected");
console.log('[HttpApiClient] WebSocket disconnected');
this.isConnecting = false;
this.ws = null;
// Attempt to reconnect after 5 seconds
@@ -131,19 +119,16 @@ export class HttpApiClient implements ElectronAPI {
};
this.ws.onerror = (error) => {
console.error("[HttpApiClient] WebSocket error:", error);
console.error('[HttpApiClient] WebSocket error:', error);
this.isConnecting = false;
};
} catch (error) {
console.error("[HttpApiClient] Failed to create WebSocket:", error);
console.error('[HttpApiClient] Failed to create WebSocket:', error);
this.isConnecting = false;
}
}
private subscribeToEvent(
type: EventType,
callback: EventCallback
): () => void {
private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
if (!this.eventCallbacks.has(type)) {
this.eventCallbacks.set(type, new Set());
}
@@ -162,18 +147,18 @@ export class HttpApiClient implements ElectronAPI {
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
'Content-Type': 'application/json',
};
const apiKey = getApiKey();
if (apiKey) {
headers["X-API-Key"] = apiKey;
headers['X-API-Key'] = apiKey;
}
return headers;
}
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "POST",
method: 'POST',
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
@@ -188,7 +173,7 @@ export class HttpApiClient implements ElectronAPI {
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "PUT",
method: 'PUT',
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
@@ -197,7 +182,7 @@ export class HttpApiClient implements ElectronAPI {
private async httpDelete<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "DELETE",
method: 'DELETE',
headers: this.getHeaders(),
});
return response.json();
@@ -205,15 +190,13 @@ export class HttpApiClient implements ElectronAPI {
// Basic operations
async ping(): Promise<string> {
const result = await this.get<{ status: string }>("/api/health");
return result.status === "ok" ? "pong" : "error";
const result = await this.get<{ status: string }>('/api/health');
return result.status === 'ok' ? 'pong' : 'error';
}
async openExternalLink(
url: string
): Promise<{ success: boolean; error?: string }> {
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
// Open in new tab
window.open(url, "_blank", "noopener,noreferrer");
window.open(url, '_blank', 'noopener,noreferrer');
return { success: true };
}
@@ -241,9 +224,9 @@ export class HttpApiClient implements ElectronAPI {
try {
// Use anchor click approach which is most reliable for custom URL schemes
// This triggers the browser's URL handler without navigation issues
const anchor = document.createElement("a");
const anchor = document.createElement('a');
anchor.href = url;
anchor.style.display = "none";
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
@@ -251,7 +234,7 @@ export class HttpApiClient implements ElectronAPI {
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to open in editor",
error: error instanceof Error ? error.message : 'Failed to open in editor',
};
}
}
@@ -261,7 +244,7 @@ export class HttpApiClient implements ElectronAPI {
const fileBrowser = getGlobalFileBrowser();
if (!fileBrowser) {
console.error("File browser not initialized");
console.error('File browser not initialized');
return { canceled: true, filePaths: [] };
}
@@ -276,21 +259,21 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
path?: string;
error?: string;
}>("/api/fs/validate-path", { filePath: path });
}>('/api/fs/validate-path', { filePath: path });
if (result.success && result.path) {
return { canceled: false, filePaths: [result.path] };
}
console.error("Invalid directory:", result.error);
console.error('Invalid directory:', result.error);
return { canceled: true, filePaths: [] };
}
async openFile(options?: object): Promise<DialogResult> {
async openFile(_options?: object): Promise<DialogResult> {
const fileBrowser = getGlobalFileBrowser();
if (!fileBrowser) {
console.error("File browser not initialized");
console.error('File browser not initialized');
return { canceled: true, filePaths: [] };
}
@@ -301,50 +284,48 @@ export class HttpApiClient implements ElectronAPI {
return { canceled: true, filePaths: [] };
}
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath: path }
);
const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', {
filePath: path,
});
if (result.success && result.exists) {
return { canceled: false, filePaths: [path] };
}
console.error("File not found");
console.error('File not found');
return { canceled: true, filePaths: [] };
}
// File system operations
async readFile(filePath: string): Promise<FileResult> {
return this.post("/api/fs/read", { filePath });
return this.post('/api/fs/read', { filePath });
}
async writeFile(filePath: string, content: string): Promise<WriteResult> {
return this.post("/api/fs/write", { filePath, content });
return this.post('/api/fs/write', { filePath, content });
}
async mkdir(dirPath: string): Promise<WriteResult> {
return this.post("/api/fs/mkdir", { dirPath });
return this.post('/api/fs/mkdir', { dirPath });
}
async readdir(dirPath: string): Promise<ReaddirResult> {
return this.post("/api/fs/readdir", { dirPath });
return this.post('/api/fs/readdir', { dirPath });
}
async exists(filePath: string): Promise<boolean> {
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath }
);
const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', {
filePath,
});
return result.exists;
}
async stat(filePath: string): Promise<StatResult> {
return this.post("/api/fs/stat", { filePath });
return this.post('/api/fs/stat', { filePath });
}
async deleteFile(filePath: string): Promise<WriteResult> {
return this.post("/api/fs/delete", { filePath });
return this.post('/api/fs/delete', { filePath });
}
async trashItem(filePath: string): Promise<WriteResult> {
@@ -354,11 +335,9 @@ export class HttpApiClient implements ElectronAPI {
async getPath(name: string): Promise<string> {
// Server provides data directory
if (name === "userData") {
const result = await this.get<{ dataDir: string }>(
"/api/health/detailed"
);
return result.dataDir || "/data";
if (name === 'userData') {
const result = await this.get<{ dataDir: string }>('/api/health/detailed');
return result.dataDir || '/data';
}
return `/data/${name}`;
}
@@ -369,7 +348,7 @@ export class HttpApiClient implements ElectronAPI {
mimeType: string,
projectPath?: string
): Promise<SaveImageResult> {
return this.post("/api/fs/save-image", {
return this.post('/api/fs/save-image', {
data,
filename,
mimeType,
@@ -383,7 +362,7 @@ export class HttpApiClient implements ElectronAPI {
mimeType: string,
projectPath: string
): Promise<{ success: boolean; path?: string; error?: string }> {
return this.post("/api/fs/save-board-background", {
return this.post('/api/fs/save-board-background', {
data,
filename,
mimeType,
@@ -391,10 +370,8 @@ export class HttpApiClient implements ElectronAPI {
});
}
async deleteBoardBackground(
projectPath: string
): Promise<{ success: boolean; error?: string }> {
return this.post("/api/fs/delete-board-background", { projectPath });
async deleteBoardBackground(projectPath: string): Promise<{ success: boolean; error?: string }> {
return this.post('/api/fs/delete-board-background', { projectPath });
}
// CLI checks - server-side
@@ -413,7 +390,7 @@ export class HttpApiClient implements ElectronAPI {
};
error?: string;
}> {
return this.get("/api/setup/claude-status");
return this.get('/api/setup/claude-status');
}
// Model API
@@ -423,14 +400,14 @@ export class HttpApiClient implements ElectronAPI {
models?: ModelDefinition[];
error?: string;
}> => {
return this.get("/api/models/available");
return this.get('/api/models/available');
},
checkProviders: async (): Promise<{
success: boolean;
providers?: Record<string, ProviderStatus>;
error?: string;
}> => {
return this.get("/api/models/providers");
return this.get('/api/models/providers');
},
};
@@ -456,13 +433,13 @@ export class HttpApiClient implements ElectronAPI {
hasRecentActivity?: boolean;
};
error?: string;
}> => this.get("/api/setup/claude-status"),
}> => this.get('/api/setup/claude-status'),
installClaude: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post("/api/setup/install-claude"),
}> => this.post('/api/setup/install-claude'),
authClaude: (): Promise<{
success: boolean;
@@ -473,7 +450,7 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
message?: string;
output?: string;
}> => this.post("/api/setup/auth-claude"),
}> => this.post('/api/setup/auth-claude'),
storeApiKey: (
provider: string,
@@ -481,7 +458,7 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{
success: boolean;
error?: string;
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
}> => this.post('/api/setup/store-api-key', { provider, apiKey }),
deleteApiKey: (
provider: string
@@ -489,13 +466,13 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
error?: string;
message?: string;
}> => this.post("/api/setup/delete-api-key", { provider }),
}> => this.post('/api/setup/delete-api-key', { provider }),
getApiKeys: (): Promise<{
success: boolean;
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
}> => this.get("/api/setup/api-keys"),
}> => this.get('/api/setup/api-keys'),
getPlatform: (): Promise<{
success: boolean;
@@ -505,15 +482,15 @@ export class HttpApiClient implements ElectronAPI {
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
}> => this.get("/api/setup/platform"),
}> => this.get('/api/setup/platform'),
verifyClaudeAuth: (
authMethod?: "cli" | "api_key"
authMethod?: 'cli' | 'api_key'
): Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}> => this.post("/api/setup/verify-claude-auth", { authMethod }),
}> => this.post('/api/setup/verify-claude-auth', { authMethod }),
getGhStatus: (): Promise<{
success: boolean;
@@ -523,76 +500,65 @@ export class HttpApiClient implements ElectronAPI {
path: string | null;
user: string | null;
error?: string;
}> => this.get("/api/setup/gh-status"),
}> => this.get('/api/setup/gh-status'),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
return this.subscribeToEvent('agent:stream', callback);
},
onAuthProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
return this.subscribeToEvent('agent:stream', callback);
},
};
// Features API
features: FeaturesAPI = {
getAll: (projectPath: string) =>
this.post("/api/features/list", { projectPath }),
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
get: (projectPath: string, featureId: string) =>
this.post("/api/features/get", { projectPath, featureId }),
this.post('/api/features/get', { projectPath, featureId }),
create: (projectPath: string, feature: Feature) =>
this.post("/api/features/create", { projectPath, feature }),
update: (
projectPath: string,
featureId: string,
updates: Partial<Feature>
) => this.post("/api/features/update", { projectPath, featureId, updates }),
this.post('/api/features/create', { projectPath, feature }),
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
this.post('/api/features/update', { projectPath, featureId, updates }),
delete: (projectPath: string, featureId: string) =>
this.post("/api/features/delete", { projectPath, featureId }),
this.post('/api/features/delete', { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>
this.post("/api/features/agent-output", { projectPath, featureId }),
this.post('/api/features/agent-output', { projectPath, featureId }),
generateTitle: (description: string) =>
this.post("/api/features/generate-title", { description }),
this.post('/api/features/generate-title', { description }),
};
// Auto Mode API
autoMode: AutoModeAPI = {
start: (projectPath: string, maxConcurrency?: number) =>
this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
stop: (projectPath: string) =>
this.post("/api/auto-mode/stop", { projectPath }),
stopFeature: (featureId: string) =>
this.post("/api/auto-mode/stop-feature", { featureId }),
status: (projectPath?: string) =>
this.post("/api/auto-mode/status", { projectPath }),
this.post('/api/auto-mode/start', { projectPath, maxConcurrency }),
stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }),
stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }),
status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }),
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean,
worktreePath?: string
) =>
this.post("/api/auto-mode/run-feature", {
this.post('/api/auto-mode/run-feature', {
projectPath,
featureId,
useWorktrees,
worktreePath,
}),
verifyFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
resumeFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
) =>
this.post("/api/auto-mode/resume-feature", {
this.post('/api/auto-mode/verify-feature', { projectPath, featureId }),
resumeFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
this.post('/api/auto-mode/resume-feature', {
projectPath,
featureId,
useWorktrees,
}),
contextExists: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
this.post('/api/auto-mode/context-exists', { projectPath, featureId }),
analyzeProject: (projectPath: string) =>
this.post("/api/auto-mode/analyze-project", { projectPath }),
this.post('/api/auto-mode/analyze-project', { projectPath }),
followUpFeature: (
projectPath: string,
featureId: string,
@@ -600,19 +566,15 @@ export class HttpApiClient implements ElectronAPI {
imagePaths?: string[],
worktreePath?: string
) =>
this.post("/api/auto-mode/follow-up-feature", {
this.post('/api/auto-mode/follow-up-feature', {
projectPath,
featureId,
prompt,
imagePaths,
worktreePath,
}),
commitFeature: (
projectPath: string,
featureId: string,
worktreePath?: string
) =>
this.post("/api/auto-mode/commit-feature", {
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
this.post('/api/auto-mode/commit-feature', {
projectPath,
featureId,
worktreePath,
@@ -624,7 +586,7 @@ export class HttpApiClient implements ElectronAPI {
editedPlan?: string,
feedback?: string
) =>
this.post("/api/auto-mode/approve-plan", {
this.post('/api/auto-mode/approve-plan', {
projectPath,
featureId,
approved,
@@ -632,10 +594,7 @@ export class HttpApiClient implements ElectronAPI {
feedback,
}),
onEvent: (callback: (event: AutoModeEvent) => void) => {
return this.subscribeToEvent(
"auto-mode:event",
callback as EventCallback
);
return this.subscribeToEvent('auto-mode:event', callback as EventCallback);
},
};
@@ -646,7 +605,7 @@ export class HttpApiClient implements ElectronAPI {
enhancementMode: string,
model?: string
): Promise<EnhancePromptResult> =>
this.post("/api/enhance-prompt", {
this.post('/api/enhance-prompt', {
originalText,
enhancementMode,
model,
@@ -656,86 +615,74 @@ export class HttpApiClient implements ElectronAPI {
// Worktree API
worktree: WorktreeAPI = {
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
this.post("/api/worktree/merge", { projectPath, featureId, options }),
this.post('/api/worktree/merge', { projectPath, featureId, options }),
getInfo: (projectPath: string, featureId: string) =>
this.post("/api/worktree/info", { projectPath, featureId }),
this.post('/api/worktree/info', { projectPath, featureId }),
getStatus: (projectPath: string, featureId: string) =>
this.post("/api/worktree/status", { projectPath, featureId }),
list: (projectPath: string) =>
this.post("/api/worktree/list", { projectPath }),
this.post('/api/worktree/status', { projectPath, featureId }),
list: (projectPath: string) => this.post('/api/worktree/list', { projectPath }),
listAll: (projectPath: string, includeDetails?: boolean) =>
this.post("/api/worktree/list", { projectPath, includeDetails }),
this.post('/api/worktree/list', { projectPath, includeDetails }),
create: (projectPath: string, branchName: string, baseBranch?: string) =>
this.post("/api/worktree/create", {
this.post('/api/worktree/create', {
projectPath,
branchName,
baseBranch,
}),
delete: (
projectPath: string,
worktreePath: string,
deleteBranch?: boolean
) =>
this.post("/api/worktree/delete", {
delete: (projectPath: string, worktreePath: string, deleteBranch?: boolean) =>
this.post('/api/worktree/delete', {
projectPath,
worktreePath,
deleteBranch,
}),
commit: (worktreePath: string, message: string) =>
this.post("/api/worktree/commit", { worktreePath, message }),
this.post('/api/worktree/commit', { worktreePath, message }),
push: (worktreePath: string, force?: boolean) =>
this.post("/api/worktree/push", { worktreePath, force }),
this.post('/api/worktree/push', { worktreePath, force }),
createPR: (worktreePath: string, options?: any) =>
this.post("/api/worktree/create-pr", { worktreePath, ...options }),
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
getDiffs: (projectPath: string, featureId: string) =>
this.post("/api/worktree/diffs", { projectPath, featureId }),
this.post('/api/worktree/diffs', { projectPath, featureId }),
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
this.post("/api/worktree/file-diff", {
this.post('/api/worktree/file-diff', {
projectPath,
featureId,
filePath,
}),
pull: (worktreePath: string) =>
this.post("/api/worktree/pull", { worktreePath }),
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
checkoutBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/checkout-branch", { worktreePath, branchName }),
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
listBranches: (worktreePath: string) =>
this.post("/api/worktree/list-branches", { worktreePath }),
this.post('/api/worktree/list-branches', { worktreePath }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
openInEditor: (worktreePath: string) =>
this.post("/api/worktree/open-in-editor", { worktreePath }),
getDefaultEditor: () => this.get("/api/worktree/default-editor"),
initGit: (projectPath: string) =>
this.post("/api/worktree/init-git", { projectPath }),
this.post('/api/worktree/open-in-editor', { worktreePath }),
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
stopDevServer: (worktreePath: string) =>
this.post("/api/worktree/stop-dev", { worktreePath }),
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }),
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
getPRInfo: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/pr-info", { worktreePath, branchName }),
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
};
// Git API
git: GitAPI = {
getDiffs: (projectPath: string) =>
this.post("/api/git/diffs", { projectPath }),
getDiffs: (projectPath: string) => this.post('/api/git/diffs', { projectPath }),
getFileDiff: (projectPath: string, filePath: string) =>
this.post("/api/git/file-diff", { projectPath, filePath }),
this.post('/api/git/file-diff', { projectPath, filePath }),
};
// Suggestions API
suggestions: SuggestionsAPI = {
generate: (projectPath: string, suggestionType?: SuggestionType) =>
this.post("/api/suggestions/generate", { projectPath, suggestionType }),
stop: () => this.post("/api/suggestions/stop"),
status: () => this.get("/api/suggestions/status"),
this.post('/api/suggestions/generate', { projectPath, suggestionType }),
stop: () => this.post('/api/suggestions/stop'),
status: () => this.get('/api/suggestions/status'),
onEvent: (callback: (event: SuggestionsEvent) => void) => {
return this.subscribeToEvent(
"suggestions:event",
callback as EventCallback
);
return this.subscribeToEvent('suggestions:event', callback as EventCallback);
},
};
@@ -748,7 +695,7 @@ export class HttpApiClient implements ElectronAPI {
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/create", {
this.post('/api/spec-regeneration/create', {
projectPath,
projectOverview,
generateFeatures,
@@ -762,7 +709,7 @@ export class HttpApiClient implements ElectronAPI {
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/generate", {
this.post('/api/spec-regeneration/generate', {
projectPath,
projectDefinition,
generateFeatures,
@@ -770,17 +717,14 @@ export class HttpApiClient implements ElectronAPI {
maxFeatures,
}),
generateFeatures: (projectPath: string, maxFeatures?: number) =>
this.post("/api/spec-regeneration/generate-features", {
this.post('/api/spec-regeneration/generate-features', {
projectPath,
maxFeatures,
}),
stop: () => this.post("/api/spec-regeneration/stop"),
status: () => this.get("/api/spec-regeneration/status"),
stop: () => this.post('/api/spec-regeneration/stop'),
status: () => this.get('/api/spec-regeneration/status'),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
return this.subscribeToEvent(
"spec-regeneration:event",
callback as EventCallback
);
return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback);
},
};
@@ -796,7 +740,7 @@ export class HttpApiClient implements ElectronAPI {
}>;
totalCount?: number;
error?: string;
}> => this.get("/api/running-agents"),
}> => this.get('/api/running-agents'),
};
// Workspace API
@@ -805,14 +749,15 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
configured: boolean;
workspaceDir?: string;
defaultDir?: string | null;
error?: string;
}> => this.get("/api/workspace/config"),
}> => this.get('/api/workspace/config'),
getDirectories: (): Promise<{
success: boolean;
directories?: Array<{ name: string; path: string }>;
error?: string;
}> => this.get("/api/workspace/directories"),
}> => this.get('/api/workspace/directories'),
};
// Agent API
@@ -824,7 +769,7 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
messages?: Message[];
error?: string;
}> => this.post("/api/agent/start", { sessionId, workingDirectory }),
}> => this.post('/api/agent/start', { sessionId, workingDirectory }),
send: (
sessionId: string,
@@ -833,7 +778,7 @@ export class HttpApiClient implements ElectronAPI {
imagePaths?: string[],
model?: string
): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/send", {
this.post('/api/agent/send', {
sessionId,
message,
workingDirectory,
@@ -848,16 +793,16 @@ export class HttpApiClient implements ElectronAPI {
messages?: Message[];
isRunning?: boolean;
error?: string;
}> => this.post("/api/agent/history", { sessionId }),
}> => this.post('/api/agent/history', { sessionId }),
stop: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/stop", { sessionId }),
this.post('/api/agent/stop', { sessionId }),
clear: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/clear", { sessionId }),
this.post('/api/agent/clear', { sessionId }),
onStream: (callback: (data: unknown) => void): (() => void) => {
return this.subscribeToEvent("agent:stream", callback as EventCallback);
return this.subscribeToEvent('agent:stream', callback as EventCallback);
},
};
@@ -872,8 +817,140 @@ export class HttpApiClient implements ElectronAPI {
projectPath?: string;
projectName?: string;
error?: string;
}> =>
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
}> => this.post('/api/templates/clone', { repoUrl, projectName, parentDir }),
};
// Settings API - persistent file-based settings
settings = {
// Get settings status (check if migration needed)
getStatus: (): Promise<{
success: boolean;
hasGlobalSettings: boolean;
hasCredentials: boolean;
dataDir: string;
needsMigration: boolean;
}> => this.get('/api/settings/status'),
// Global settings
getGlobal: (): Promise<{
success: boolean;
settings?: {
version: number;
theme: string;
sidebarOpen: boolean;
chatHistoryOpen: boolean;
kanbanCardDetailLevel: string;
maxConcurrency: number;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
useWorktrees: boolean;
showProfilesOnly: boolean;
defaultPlanningMode: string;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
muteDoneSound: boolean;
enhancementModel: string;
keyboardShortcuts: Record<string, string>;
aiProfiles: unknown[];
projects: unknown[];
trashedProjects: unknown[];
projectHistory: string[];
projectHistoryIndex: number;
lastProjectDir?: string;
recentFolders: string[];
worktreePanelCollapsed: boolean;
lastSelectedSessionByProject: Record<string, string>;
};
error?: string;
}> => this.get('/api/settings/global'),
updateGlobal: (
updates: Record<string, unknown>
): Promise<{
success: boolean;
settings?: Record<string, unknown>;
error?: string;
}> => this.put('/api/settings/global', updates),
// Credentials (masked for security)
getCredentials: (): Promise<{
success: boolean;
credentials?: {
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
};
error?: string;
}> => this.get('/api/settings/credentials'),
updateCredentials: (updates: {
apiKeys?: { anthropic?: string; google?: string; openai?: string };
}): Promise<{
success: boolean;
credentials?: {
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
};
error?: string;
}> => this.put('/api/settings/credentials', updates),
// Project settings
getProject: (
projectPath: string
): Promise<{
success: boolean;
settings?: {
version: number;
theme?: string;
useWorktrees?: boolean;
currentWorktree?: { path: string | null; branch: string };
worktrees?: Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>;
boardBackground?: {
imagePath: string | null;
imageVersion?: number;
cardOpacity: number;
columnOpacity: number;
columnBorderEnabled: boolean;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
hideScrollbar: boolean;
};
lastSelectedSessionId?: string;
};
error?: string;
}> => this.post('/api/settings/project', { projectPath }),
updateProject: (
projectPath: string,
updates: Record<string, unknown>
): Promise<{
success: boolean;
settings?: Record<string, unknown>;
error?: string;
}> => this.put('/api/settings/project', { projectPath, updates }),
// Migration from localStorage
migrate: (data: {
'automaker-storage'?: string;
'automaker-setup'?: string;
'worktree-panel-collapsed'?: string;
'file-browser-recent-folders'?: string;
'automaker:lastProjectDir'?: string;
}): Promise<{
success: boolean;
migratedGlobalSettings: boolean;
migratedCredentials: boolean;
migratedProjectCount: number;
errors: string[];
}> => this.post('/api/settings/migrate', { data }),
};
// Sessions API
@@ -901,7 +978,7 @@ export class HttpApiClient implements ElectronAPI {
updatedAt: string;
};
error?: string;
}> => this.post("/api/sessions", { name, projectPath, workingDirectory }),
}> => this.post('/api/sessions', { name, projectPath, workingDirectory }),
update: (
sessionId: string,
@@ -910,21 +987,20 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{ success: boolean; error?: string }> =>
this.put(`/api/sessions/${sessionId}`, { name, tags }),
archive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
archive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post(`/api/sessions/${sessionId}/archive`, {}),
unarchive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
delete: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
delete: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.httpDelete(`/api/sessions/${sessionId}`),
};
// Claude API
claude = {
getUsage: (): Promise<ClaudeUsageResponse> => this.get('/api/claude/usage'),
};
}
// Singleton instance

View File

@@ -5,7 +5,7 @@
* new or existing projects.
*/
import { getElectronAPI } from "./electron";
import { getElectronAPI } from './electron';
export interface ProjectInitResult {
success: boolean;
@@ -23,14 +23,9 @@ const REQUIRED_STRUCTURE: {
directories: string[];
files: Record<string, string>;
} = {
directories: [
".automaker",
".automaker/context",
".automaker/features",
".automaker/images",
],
directories: ['.automaker', '.automaker/context', '.automaker/features', '.automaker/images'],
files: {
".automaker/categories.json": "[]",
'.automaker/categories.json': '[]',
},
};
@@ -40,37 +35,63 @@ const REQUIRED_STRUCTURE: {
* @param projectPath - The root path of the project
* @returns Result indicating what was created or if the project was already initialized
*/
export async function initializeProject(
projectPath: string
): Promise<ProjectInitResult> {
export async function initializeProject(projectPath: string): Promise<ProjectInitResult> {
const api = getElectronAPI();
const createdFiles: string[] = [];
const existingFiles: string[] = [];
try {
// Validate that the project directory exists and is a directory
const projectExists = await api.exists(projectPath);
if (!projectExists) {
return {
success: false,
isNewProject: false,
error: `Project directory does not exist: ${projectPath}. Create it first before initializing.`,
};
}
// Verify it's actually a directory (not a file)
const projectStat = await api.stat(projectPath);
if (!projectStat.success) {
return {
success: false,
isNewProject: false,
error: projectStat.error || `Failed to stat project directory: ${projectPath}`,
};
}
if (projectStat.stats && !projectStat.stats.isDirectory) {
return {
success: false,
isNewProject: false,
error: `Project path is not a directory: ${projectPath}`,
};
}
// Initialize git repository if it doesn't exist
const gitDirExists = await api.exists(`${projectPath}/.git`);
if (!gitDirExists) {
console.log("[project-init] Initializing git repository...");
console.log('[project-init] Initializing git repository...');
try {
// Initialize git and create an initial empty commit via server route
const result = await api.worktree?.initGit(projectPath);
if (result?.success && result.result?.initialized) {
createdFiles.push(".git");
console.log("[project-init] Git repository initialized with initial commit");
createdFiles.push('.git');
console.log('[project-init] Git repository initialized with initial commit');
} else if (result?.success && !result.result?.initialized) {
// Git already existed (shouldn't happen since we checked, but handle it)
existingFiles.push(".git");
console.log("[project-init] Git repository already exists");
existingFiles.push('.git');
console.log('[project-init] Git repository already exists');
} else {
console.warn("[project-init] Failed to initialize git repository:", result?.error);
console.warn('[project-init] Failed to initialize git repository:', result?.error);
}
} catch (gitError) {
console.warn("[project-init] Failed to initialize git repository:", gitError);
console.warn('[project-init] Failed to initialize git repository:', gitError);
// Don't fail the whole initialization if git init fails
}
} else {
existingFiles.push(".git");
existingFiles.push('.git');
}
// Create all required directories
@@ -80,9 +101,7 @@ export async function initializeProject(
}
// Check and create required files
for (const [relativePath, defaultContent] of Object.entries(
REQUIRED_STRUCTURE.files
)) {
for (const [relativePath, defaultContent] of Object.entries(REQUIRED_STRUCTURE.files)) {
const fullPath = `${projectPath}/${relativePath}`;
const exists = await api.exists(fullPath);
@@ -95,8 +114,7 @@ export async function initializeProject(
}
// Determine if this is a new project (no files needed to be created since features/ is empty by default)
const isNewProject =
createdFiles.length === 0 && existingFiles.length === 0;
const isNewProject = createdFiles.length === 0 && existingFiles.length === 0;
return {
success: true,
@@ -105,11 +123,11 @@ export async function initializeProject(
existingFiles,
};
} catch (error) {
console.error("[project-init] Failed to initialize project:", error);
console.error('[project-init] Failed to initialize project:', error);
return {
success: false,
isNewProject: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
@@ -120,9 +138,7 @@ export async function initializeProject(
* @param projectPath - The root path of the project
* @returns true if all required files/directories exist
*/
export async function isProjectInitialized(
projectPath: string
): Promise<boolean> {
export async function isProjectInitialized(projectPath: string): Promise<boolean> {
const api = getElectronAPI();
try {
@@ -137,10 +153,7 @@ export async function isProjectInitialized(
return true;
} catch (error) {
console.error(
"[project-init] Error checking project initialization:",
error
);
console.error('[project-init] Error checking project initialization:', error);
return false;
}
}
@@ -178,7 +191,7 @@ export async function getProjectInitStatus(projectPath: string): Promise<{
existingFiles,
};
} catch (error) {
console.error("[project-init] Error getting project status:", error);
console.error('[project-init] Error getting project status:', error);
return {
initialized: false,
missingFiles: REQUIRED_STRUCTURE.directories,
@@ -199,7 +212,7 @@ export async function hasAppSpec(projectPath: string): Promise<boolean> {
const fullPath = `${projectPath}/.automaker/app_spec.txt`;
return await api.exists(fullPath);
} catch (error) {
console.error("[project-init] Error checking app_spec.txt:", error);
console.error('[project-init] Error checking app_spec.txt:', error);
return false;
}
}
@@ -216,7 +229,7 @@ export async function hasAutomakerDir(projectPath: string): Promise<boolean> {
const fullPath = `${projectPath}/.automaker`;
return await api.exists(fullPath);
} catch (error) {
console.error("[project-init] Error checking .automaker dir:", error);
console.error('[project-init] Error checking .automaker dir:', error);
return false;
}
}

100
apps/ui/src/lib/storage.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* Centralized localStorage abstraction module
*
* Provides type-safe wrappers for all localStorage operations.
* All localStorage access should go through this module to ensure
* consistent error handling and environment checks.
*/
/**
* Check if localStorage is available in the current environment
*/
function isStorageAvailable(): boolean {
return typeof window !== 'undefined' && window.localStorage !== undefined;
}
/**
* Get an item from localStorage
* @param key - The storage key
* @returns The stored value or null if not found/unavailable
*/
export function getItem(key: string): string | null {
if (!isStorageAvailable()) return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
/**
* Set an item in localStorage
* @param key - The storage key
* @param value - The value to store
* @returns true if successful, false otherwise
*/
export function setItem(key: string, value: string): boolean {
if (!isStorageAvailable()) return false;
try {
window.localStorage.setItem(key, value);
return true;
} catch {
return false;
}
}
/**
* Remove an item from localStorage
* @param key - The storage key to remove
* @returns true if successful, false otherwise
*/
export function removeItem(key: string): boolean {
if (!isStorageAvailable()) return false;
try {
window.localStorage.removeItem(key);
return true;
} catch {
return false;
}
}
/**
* Get a JSON-parsed item from localStorage
* @param key - The storage key
* @returns The parsed value or null if not found/invalid
*/
export function getJSON<T>(key: string): T | null {
const value = getItem(key);
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
/**
* Set a JSON-stringified item in localStorage
* @param key - The storage key
* @param value - The value to stringify and store
* @returns true if successful, false otherwise
*/
export function setJSON<T>(key: string, value: T): boolean {
try {
return setItem(key, JSON.stringify(value));
} catch {
return false;
}
}
/**
* Storage module for named exports
*/
export const storage = {
getItem,
setItem,
removeItem,
getJSON,
setJSON,
isAvailable: isStorageAvailable,
};

View File

@@ -1,15 +1,15 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { AgentModel } from "@/store/app-store"
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { AgentModel } from '@/store/app-store';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
/**
* Determine if the current model supports extended thinking controls
*/
export function modelSupportsThinking(model?: AgentModel | string): boolean {
export function modelSupportsThinking(_model?: AgentModel | string): boolean {
// All Claude models support thinking
return true;
}
@@ -19,9 +19,9 @@ export function modelSupportsThinking(model?: AgentModel | string): boolean {
*/
export function getModelDisplayName(model: AgentModel | string): string {
const displayNames: Record<string, string> = {
haiku: "Claude Haiku",
sonnet: "Claude Sonnet",
opus: "Claude Opus",
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',
opus: 'Claude Opus',
};
return displayNames[model] || model;
}
@@ -41,7 +41,7 @@ export function truncateDescription(description: string, maxLength = 50): string
* This is important for cross-platform compatibility (Windows uses backslashes).
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
return p.replace(/\\/g, '/');
}
/**

View File

@@ -0,0 +1,106 @@
/**
* Utility functions for determining default workspace directories
* Centralizes the logic for determining where projects should be created/opened
*/
import { getHttpApiClient } from './http-api-client';
import { getElectronAPI } from './electron';
import { getItem, setItem } from './storage';
import path from 'path';
const LAST_PROJECT_DIR_KEY = 'automaker:lastProjectDir';
/**
* Gets the default Documents/Automaker directory path
* @returns Promise resolving to Documents/Automaker path, or null if unavailable
*/
async function getDefaultDocumentsPath(): Promise<string | null> {
try {
const api = getElectronAPI();
const documentsPath = await api.getPath('documents');
return path.join(documentsPath, 'Automaker');
} catch (error) {
if (typeof window !== 'undefined' && window.console) {
window.console.error('Failed to get documents path:', error);
}
return null;
}
}
/**
* Determines the default directory for project creation/opening
* Priority order:
* 1. ALLOWED_ROOT_DIRECTORY (if configured)
* 2. Last used directory from localStorage (if ALLOWED_ROOT_DIRECTORY is not set)
* 3. Documents/Automaker (if ALLOWED_ROOT_DIRECTORY is not set)
* 4. DATA_DIR (if ALLOWED_ROOT_DIRECTORY is not set and Documents unavailable)
* 5. null (no default)
*
* @returns Promise resolving to the default directory path, or null if none available
*/
export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
try {
const httpClient = getHttpApiClient();
const result = await httpClient.workspace.getConfig();
if (result.success) {
// If ALLOWED_ROOT_DIRECTORY is configured, use it
if (result.configured && result.workspaceDir) {
return result.workspaceDir;
}
// If ALLOWED_ROOT_DIRECTORY is not set, use priority:
// 1. Last used directory
// 2. Documents/Automaker
// 3. DATA_DIR as fallback
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
if (lastUsedDir) {
return lastUsedDir;
}
// Try to get Documents/Automaker
const documentsPath = await getDefaultDocumentsPath();
if (documentsPath) {
return documentsPath;
}
// Fallback to DATA_DIR if available
if (result.defaultDir) {
return result.defaultDir;
}
}
// If API call failed, still try last used dir and Documents
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
if (lastUsedDir) {
return lastUsedDir;
}
const documentsPath = await getDefaultDocumentsPath();
return documentsPath;
} catch (error) {
if (typeof window !== 'undefined' && window.console) {
window.console.error('Failed to get default workspace directory:', error);
}
// On error, try last used dir and Documents
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
if (lastUsedDir) {
return lastUsedDir;
}
const documentsPath = await getDefaultDocumentsPath();
return documentsPath;
}
}
/**
* Saves the last used project directory to localStorage
* @param path - The directory path to save
*/
export function saveLastProjectDirectory(path: string): void {
setItem(LAST_PROJECT_DIR_KEY, path);
}