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

@@ -0,0 +1,95 @@
/**
* Conversation history utilities for processing message history
*
* Provides standardized conversation history handling:
* - Extract text from content (string or array format)
* - Normalize content blocks to array format
* - Format history as plain text for CLI-based providers
* - Convert history to Claude SDK message format
*/
import type { ConversationMessage } from '@automaker/types';
/**
* Extract plain text from message content (handles both string and array formats)
*
* @param content - Message content (string or array of content blocks)
* @returns Extracted text content
*/
export function extractTextFromContent(
content: string | Array<{ type: string; text?: string; source?: object }>
): string {
if (typeof content === 'string') {
return content;
}
// Extract text blocks only
return content
.filter((block) => block.type === 'text')
.map((block) => block.text || '')
.join('\n');
}
/**
* Normalize message content to array format
*
* @param content - Message content (string or array)
* @returns Content as array of blocks
*/
export function normalizeContentBlocks(
content: string | Array<{ type: string; text?: string; source?: object }>
): Array<{ type: string; text?: string; source?: object }> {
if (Array.isArray(content)) {
return content;
}
return [{ type: 'text', text: content }];
}
/**
* Format conversation history as plain text for CLI-based providers
*
* @param history - Array of conversation messages
* @returns Formatted text with role labels
*/
export function formatHistoryAsText(history: ConversationMessage[]): string {
if (history.length === 0) {
return '';
}
let historyText = 'Previous conversation:\n\n';
for (const msg of history) {
const contentText = extractTextFromContent(msg.content);
const role = msg.role === 'user' ? 'User' : 'Assistant';
historyText += `${role}: ${contentText}\n\n`;
}
historyText += '---\n\n';
return historyText;
}
/**
* Convert conversation history to Claude SDK message format
*
* @param history - Array of conversation messages
* @returns Array of Claude SDK formatted messages
*/
export function convertHistoryToMessages(history: ConversationMessage[]): Array<{
type: 'user' | 'assistant';
session_id: string;
message: {
role: 'user' | 'assistant';
content: Array<{ type: string; text?: string; source?: object }>;
};
parent_tool_use_id: null;
}> {
return history.map((historyMsg) => ({
type: historyMsg.role,
session_id: '',
message: {
role: historyMsg.role,
content: normalizeContentBlocks(historyMsg.content),
},
parent_tool_use_id: null,
}));
}

View File

@@ -0,0 +1,131 @@
/**
* Error handling utilities for standardized error classification
*
* Provides utilities for:
* - Detecting abort/cancellation errors
* - Detecting authentication errors
* - Classifying errors by type
* - Generating user-friendly error messages
*/
import type { ErrorType, ErrorInfo } from '@automaker/types';
/**
* Check if an error is an abort/cancellation error
*
* @param error - The error to check
* @returns True if the error is an abort error
*/
export function isAbortError(error: unknown): boolean {
return error instanceof Error && (error.name === 'AbortError' || error.message.includes('abort'));
}
/**
* Check if an error is a user-initiated cancellation
*
* @param errorMessage - The error message to check
* @returns True if the error is a user-initiated cancellation
*/
export function isCancellationError(errorMessage: string): boolean {
const lowerMessage = errorMessage.toLowerCase();
return (
lowerMessage.includes('cancelled') ||
lowerMessage.includes('canceled') ||
lowerMessage.includes('stopped') ||
lowerMessage.includes('aborted')
);
}
/**
* Check if an error is an authentication/API key error
*
* @param errorMessage - The error message to check
* @returns True if the error is authentication-related
*/
export function isAuthenticationError(errorMessage: string): boolean {
return (
errorMessage.includes('Authentication failed') ||
errorMessage.includes('Invalid API key') ||
errorMessage.includes('authentication_failed') ||
errorMessage.includes('Fix external API key')
);
}
/**
* Classify an error into a specific type
*
* @param error - The error to classify
* @returns Classified error information
*/
export function classifyError(error: unknown): ErrorInfo {
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
const isAbort = isAbortError(error);
const isAuth = isAuthenticationError(message);
const isCancellation = isCancellationError(message);
let type: ErrorType;
if (isAuth) {
type = 'authentication';
} else if (isAbort) {
type = 'abort';
} else if (isCancellation) {
type = 'cancellation';
} else if (error instanceof Error) {
type = 'execution';
} else {
type = 'unknown';
}
return {
type,
message,
isAbort,
isAuth,
isCancellation,
originalError: error,
};
}
/**
* Get a user-friendly error message
*
* @param error - The error to convert
* @returns User-friendly error message
*/
export function getUserFriendlyErrorMessage(error: unknown): string {
const info = classifyError(error);
if (info.isAbort) {
return 'Operation was cancelled';
}
if (info.isAuth) {
return 'Authentication failed. Please check your API key.';
}
return info.message;
}
/**
* Extract error message from an unknown error value
*
* Simple utility for getting a string error message from any error type.
* Returns the error's message property if it's an Error, otherwise
* converts to string. Used throughout the codebase for consistent
* error message extraction.
*
* @param error - The error value (Error object, string, or unknown)
* @returns Error message string
*
* @example
* ```typescript
* try {
* throw new Error("Something went wrong");
* } catch (error) {
* const message = getErrorMessage(error); // "Something went wrong"
* }
* ```
*/
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error';
}

View File

@@ -0,0 +1,67 @@
/**
* File system utilities that handle symlinks safely
*/
import { secureFs } from '@automaker/platform';
import path from 'path';
/**
* Create a directory, handling symlinks safely to avoid ELOOP errors.
* If the path already exists as a directory or symlink, returns success.
*/
export async function mkdirSafe(dirPath: string): Promise<void> {
const resolvedPath = path.resolve(dirPath);
// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await secureFs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
return;
}
// It's a file - can't create directory
throw new Error(`Path exists and is not a directory: ${resolvedPath}`);
} catch (error: any) {
// ENOENT means path doesn't exist - we should create it
if (error.code !== 'ENOENT') {
// Some other error (could be ELOOP in parent path)
// If it's ELOOP, the path involves symlinks - don't try to create
if (error.code === 'ELOOP') {
console.warn(`[fs-utils] Symlink loop detected at ${resolvedPath}, skipping mkdir`);
return;
}
throw error;
}
}
// Path doesn't exist, create it
try {
await secureFs.mkdir(resolvedPath, { recursive: true });
} catch (error: any) {
// Handle race conditions and symlink issues
if (error.code === 'EEXIST' || error.code === 'ELOOP') {
return;
}
throw error;
}
}
/**
* Check if a path exists, handling symlinks safely.
* Returns true if the path exists as a file, directory, or symlink.
*/
export async function existsSafe(filePath: string): Promise<boolean> {
try {
await secureFs.lstat(filePath);
return true;
} catch (error: any) {
if (error.code === 'ENOENT') {
return false;
}
// ELOOP or other errors - path exists but is problematic
if (error.code === 'ELOOP') {
return true; // Symlink exists, even if looping
}
throw error;
}
}

View File

@@ -0,0 +1,113 @@
/**
* Image handling utilities for processing image files
*
* Provides utilities for:
* - MIME type detection based on file extensions
* - Base64 encoding of image files
* - Content block generation for Claude SDK format
* - Path resolution (relative/absolute)
*/
import { secureFs } from '@automaker/platform';
import path from 'path';
import type { ImageData, ImageContentBlock } from '@automaker/types';
/**
* MIME type mapping for image file extensions
*/
const IMAGE_MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
} as const;
/**
* Get MIME type for an image file based on extension
*
* @param imagePath - Path to the image file
* @returns MIME type string (defaults to "image/png" for unknown extensions)
*/
export function getMimeTypeForImage(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
return IMAGE_MIME_TYPES[ext] || 'image/png';
}
/**
* Read an image file and convert to base64 with metadata
*
* @param imagePath - Path to the image file
* @returns Promise resolving to image data with base64 encoding
* @throws Error if file cannot be read
*/
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
const imageBuffer = (await secureFs.readFile(imagePath)) as Buffer;
const base64Data = imageBuffer.toString('base64');
const mimeType = getMimeTypeForImage(imagePath);
return {
base64: base64Data,
mimeType,
filename: path.basename(imagePath),
originalPath: imagePath,
};
}
/**
* Convert image paths to content blocks (Claude SDK format)
* Handles both relative and absolute paths
*
* @param imagePaths - Array of image file paths
* @param workDir - Optional working directory for resolving relative paths
* @returns Promise resolving to array of image content blocks
*/
export async function convertImagesToContentBlocks(
imagePaths: string[],
workDir?: string
): Promise<ImageContentBlock[]> {
const blocks: ImageContentBlock[] = [];
for (const imagePath of imagePaths) {
try {
// Resolve to absolute path if needed
const absolutePath =
workDir && !path.isAbsolute(imagePath) ? path.join(workDir, imagePath) : imagePath;
const imageData = await readImageAsBase64(absolutePath);
blocks.push({
type: 'image',
source: {
type: 'base64',
media_type: imageData.mimeType,
data: imageData.base64,
},
});
} catch (error) {
console.error(`[ImageHandler] Failed to load image ${imagePath}:`, error);
// Continue processing other images
}
}
return blocks;
}
/**
* Build a list of image paths for text prompts
* Formats image paths as a bulleted list for inclusion in text prompts
*
* @param imagePaths - Array of image file paths
* @returns Formatted string with image paths, or empty string if no images
*/
export function formatImagePathsForPrompt(imagePaths: string[]): string {
if (imagePaths.length === 0) {
return '';
}
let text = '\n\nAttached images:\n';
for (const imagePath of imagePaths) {
text += `- ${imagePath}\n`;
}
return text;
}

46
libs/utils/src/index.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* @automaker/utils
* Shared utility functions for AutoMaker
*/
// Error handling
export {
isAbortError,
isCancellationError,
isAuthenticationError,
classifyError,
getUserFriendlyErrorMessage,
getErrorMessage,
} from './error-handler.js';
// Conversation utilities
export {
extractTextFromContent,
normalizeContentBlocks,
formatHistoryAsText,
convertHistoryToMessages,
} from './conversation-utils.js';
// Image handling
export {
getMimeTypeForImage,
readImageAsBase64,
convertImagesToContentBlocks,
formatImagePathsForPrompt,
} from './image-handler.js';
// Prompt building
export {
buildPromptWithImages,
type PromptContent,
type PromptWithImages,
} from './prompt-builder.js';
// Logger
export { createLogger, getLogLevel, setLogLevel, LogLevel } from './logger.js';
// File system utilities
export { mkdirSafe, existsSafe } from './fs-utils.js';
// Path utilities
export { normalizePath, pathsEqual } from './path-utils.js';

74
libs/utils/src/logger.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* Simple logger utility with log levels
* Configure via LOG_LEVEL environment variable: error, warn, info, debug
* Defaults to 'info' if not set
*/
export enum LogLevel {
ERROR = 0,
WARN = 1,
INFO = 2,
DEBUG = 3,
}
const LOG_LEVEL_NAMES: Record<string, LogLevel> = {
error: LogLevel.ERROR,
warn: LogLevel.WARN,
info: LogLevel.INFO,
debug: LogLevel.DEBUG,
};
let currentLogLevel: LogLevel = LogLevel.INFO;
// Initialize log level from environment variable
const envLogLevel = process.env.LOG_LEVEL?.toLowerCase();
if (envLogLevel && LOG_LEVEL_NAMES[envLogLevel] !== undefined) {
currentLogLevel = LOG_LEVEL_NAMES[envLogLevel];
}
/**
* Create a logger instance with a context prefix
*/
export function createLogger(context: string) {
const prefix = `[${context}]`;
return {
error: (...args: unknown[]): void => {
if (currentLogLevel >= LogLevel.ERROR) {
console.error(prefix, ...args);
}
},
warn: (...args: unknown[]): void => {
if (currentLogLevel >= LogLevel.WARN) {
console.warn(prefix, ...args);
}
},
info: (...args: unknown[]): void => {
if (currentLogLevel >= LogLevel.INFO) {
console.log(prefix, ...args);
}
},
debug: (...args: unknown[]): void => {
if (currentLogLevel >= LogLevel.DEBUG) {
console.log(prefix, '[DEBUG]', ...args);
}
},
};
}
/**
* Get the current log level
*/
export function getLogLevel(): LogLevel {
return currentLogLevel;
}
/**
* Set the log level programmatically (useful for testing)
*/
export function setLogLevel(level: LogLevel): void {
currentLogLevel = level;
}

View File

@@ -0,0 +1,51 @@
/**
* Path Utilities - Cross-platform path manipulation helpers
*
* Provides functions for normalizing and comparing file system paths
* across different operating systems (Windows, macOS, Linux).
*/
/**
* Normalize a path by converting backslashes to forward slashes
*
* This ensures consistent path representation across platforms:
* - Windows: C:\Users\foo\bar -> C:/Users/foo/bar
* - Unix: /home/foo/bar -> /home/foo/bar (unchanged)
*
* @param p - Path string to normalize
* @returns Normalized path with forward slashes
*
* @example
* ```typescript
* normalizePath("C:\\Users\\foo\\bar"); // "C:/Users/foo/bar"
* normalizePath("/home/foo/bar"); // "/home/foo/bar"
* ```
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, '/');
}
/**
* Compare two paths for equality after normalization
*
* Handles null/undefined values and normalizes paths before comparison.
* Useful for checking if two paths refer to the same location regardless
* of platform-specific path separators.
*
* @param p1 - First path to compare (or null/undefined)
* @param p2 - Second path to compare (or null/undefined)
* @returns true if paths are equal (or both null/undefined), false otherwise
*
* @example
* ```typescript
* pathsEqual("C:\\foo\\bar", "C:/foo/bar"); // true
* pathsEqual("/home/user", "/home/user"); // true
* pathsEqual("/home/user", "/home/other"); // false
* pathsEqual(null, undefined); // false
* pathsEqual(null, null); // true
* ```
*/
export function pathsEqual(p1: string | undefined | null, p2: string | undefined | null): boolean {
if (!p1 || !p2) return p1 === p2;
return normalizePath(p1) === normalizePath(p2);
}

View File

@@ -0,0 +1,79 @@
/**
* Prompt building utilities for constructing prompts with images
*
* Provides standardized prompt building that:
* - Combines text prompts with image attachments
* - Handles content block array generation
* - Optionally includes image paths in text
* - Supports both vision and non-vision models
*/
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from './image-handler.js';
/**
* Content that can be either simple text or structured blocks
*/
export type PromptContent =
| string
| Array<{
type: string;
text?: string;
source?: object;
}>;
/**
* Result of building a prompt with optional images
*/
export interface PromptWithImages {
content: PromptContent;
hasImages: boolean;
}
/**
* Build a prompt with optional image attachments
*
* @param basePrompt - The text prompt
* @param imagePaths - Optional array of image file paths
* @param workDir - Optional working directory for resolving relative paths
* @param includeImagePaths - Whether to append image paths to the text (default: false)
* @returns Promise resolving to prompt content and metadata
*/
export async function buildPromptWithImages(
basePrompt: string,
imagePaths?: string[],
workDir?: string,
includeImagePaths: boolean = false
): Promise<PromptWithImages> {
// No images - return plain text
if (!imagePaths || imagePaths.length === 0) {
return { content: basePrompt, hasImages: false };
}
// Build text content with optional image path listing
let textContent = basePrompt;
if (includeImagePaths) {
textContent += formatImagePathsForPrompt(imagePaths);
}
// Build content blocks array
const contentBlocks: Array<{
type: string;
text?: string;
source?: object;
}> = [];
// Add text block if we have text
if (textContent.trim()) {
contentBlocks.push({ type: 'text', text: textContent });
}
// Add image blocks
const imageBlocks = await convertImagesToContentBlocks(imagePaths, workDir);
contentBlocks.push(...imageBlocks);
// Return appropriate format
const content: PromptContent =
contentBlocks.length > 1 || contentBlocks[0]?.type === 'image' ? contentBlocks : textContent;
return { content, hasImages: true };
}