Merge origin/main into feature/shared-packages

Resolved conflicts:
- list.ts: Keep @automaker/git-utils import, add worktree-metadata import
- feature-loader.ts: Use Feature type from @automaker/types
- automaker-paths.test.ts: Import from @automaker/platform
- kanban-card.tsx: Accept deletion (split into components/)
- subprocess.test.ts: Keep libs/platform location

Added missing exports to @automaker/platform:
- getGlobalSettingsPath, getCredentialsPath, getProjectSettingsPath, ensureDataDir

Added title and titleGenerating fields to @automaker/types Feature interface.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-20 22:20:17 +01:00
108 changed files with 10834 additions and 3489 deletions

View File

@@ -37,10 +37,12 @@ import {
isTerminalEnabled,
isTerminalPasswordRequired,
} from "./routes/terminal/index.js";
import { createSettingsRoutes } from "./routes/settings/index.js";
import { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js";
import { AutoModeService } from "./services/auto-mode-service.js";
import { getTerminalService } from "./services/terminal-service.js";
import { SettingsService } from "./services/settings-service.js";
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
// Load environment variables
@@ -108,6 +110,7 @@ const events: EventEmitter = createEventEmitter();
const agentService = new AgentService(DATA_DIR, events);
const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events);
const settingsService = new SettingsService(DATA_DIR);
// Initialize services
(async () => {
@@ -137,6 +140,7 @@ app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
app.use("/api/workspace", createWorkspaceRoutes());
app.use("/api/templates", createTemplatesRoutes());
app.use("/api/terminal", createTerminalRoutes());
app.use("/api/settings", createSettingsRoutes(settingsService));
// Create HTTP server
const server = createServer(app);

View File

@@ -0,0 +1,183 @@
/**
* Worktree metadata storage utilities
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
*/
import * as fs from "fs/promises";
import * as path from "path";
/** Maximum length for sanitized branch names in filesystem paths */
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}
export interface WorktreeMetadata {
branch: string;
createdAt: string;
pr?: WorktreePRInfo;
}
/**
* Sanitize branch name for cross-platform filesystem safety
*/
function sanitizeBranchName(branch: string): string {
// Replace characters that are invalid or problematic on various filesystems:
// - Forward and backslashes (path separators)
// - Windows invalid chars: : * ? " < > |
// - Other potentially problematic chars
let safeBranch = branch
.replace(/[/\\:*?"<>|]/g, "-") // Replace invalid chars with dash
.replace(/\s+/g, "_") // Replace spaces with underscores
.replace(/\.+$/g, "") // Remove trailing dots (Windows issue)
.replace(/-+/g, "-") // Collapse multiple dashes
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
// Truncate to safe length (leave room for path components)
safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH);
// Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
if (windowsReserved.test(safeBranch) || safeBranch.length === 0) {
safeBranch = `_${safeBranch || "branch"}`;
}
return safeBranch;
}
/**
* Get the path to the worktree metadata directory
*/
function getWorktreeMetadataDir(projectPath: string, branch: string): string {
const safeBranch = sanitizeBranchName(branch);
return path.join(projectPath, ".automaker", "worktrees", safeBranch);
}
/**
* Get the path to the worktree metadata file
*/
function getWorktreeMetadataPath(projectPath: string, branch: string): string {
return path.join(getWorktreeMetadataDir(projectPath, branch), "worktree.json");
}
/**
* Read worktree metadata for a branch
*/
export async function readWorktreeMetadata(
projectPath: string,
branch: string
): Promise<WorktreeMetadata | null> {
try {
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
const content = await fs.readFile(metadataPath, "utf-8");
return JSON.parse(content) as WorktreeMetadata;
} catch (error) {
// File doesn't exist or can't be read
return null;
}
}
/**
* Write worktree metadata for a branch
*/
export async function writeWorktreeMetadata(
projectPath: string,
branch: string,
metadata: WorktreeMetadata
): Promise<void> {
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
// Ensure directory exists
await fs.mkdir(metadataDir, { recursive: true });
// Write metadata
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
}
/**
* Update PR info in worktree metadata
*/
export async function updateWorktreePRInfo(
projectPath: string,
branch: string,
prInfo: WorktreePRInfo
): Promise<void> {
// Read existing metadata or create new
let metadata = await readWorktreeMetadata(projectPath, branch);
if (!metadata) {
metadata = {
branch,
createdAt: new Date().toISOString(),
};
}
// Update PR info
metadata.pr = prInfo;
// Write back
await writeWorktreeMetadata(projectPath, branch, metadata);
}
/**
* Get PR info for a branch from metadata
*/
export async function getWorktreePRInfo(
projectPath: string,
branch: string
): Promise<WorktreePRInfo | null> {
const metadata = await readWorktreeMetadata(projectPath, branch);
return metadata?.pr || null;
}
/**
* Read all worktree metadata for a project
*/
export async function readAllWorktreeMetadata(
projectPath: string
): Promise<Map<string, WorktreeMetadata>> {
const result = new Map<string, WorktreeMetadata>();
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");
try {
const dirs = await fs.readdir(worktreesDir, { withFileTypes: true });
for (const dir of dirs) {
if (dir.isDirectory()) {
const metadataPath = path.join(worktreesDir, dir.name, "worktree.json");
try {
const content = await fs.readFile(metadataPath, "utf-8");
const metadata = JSON.parse(content) as WorktreeMetadata;
result.set(metadata.branch, metadata);
} catch {
// Skip if file doesn't exist or can't be read
}
}
}
} catch {
// Directory doesn't exist
}
return result;
}
/**
* Delete worktree metadata for a branch
*/
export async function deleteWorktreeMetadata(
projectPath: string,
branch: string
): Promise<void> {
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
try {
await fs.rm(metadataDir, { recursive: true, force: true });
} catch {
// Ignore errors if directory doesn't exist
}
}

View File

@@ -10,6 +10,7 @@ import { createCreateHandler } from "./routes/create.js";
import { createUpdateHandler } from "./routes/update.js";
import { createDeleteHandler } from "./routes/delete.js";
import { createAgentOutputHandler } from "./routes/agent-output.js";
import { createGenerateTitleHandler } from "./routes/generate-title.js";
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
const router = Router();
@@ -20,6 +21,7 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
router.post("/update", createUpdateHandler(featureLoader));
router.post("/delete", createDeleteHandler(featureLoader));
router.post("/agent-output", createAgentOutputHandler(featureLoader));
router.post("/generate-title", createGenerateTitleHandler());
return router;
}

View File

@@ -0,0 +1,137 @@
/**
* POST /features/generate-title endpoint - Generate a concise title from description
*
* Uses Claude Haiku to generate a short, descriptive title from feature description.
*/
import type { Request, Response } from "express";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { createLogger } from "../../../lib/logger.js";
import { CLAUDE_MODEL_MAP } from "../../../lib/model-resolver.js";
const logger = createLogger("GenerateTitle");
interface GenerateTitleRequestBody {
description: string;
}
interface GenerateTitleSuccessResponse {
success: true;
title: string;
}
interface GenerateTitleErrorResponse {
success: false;
error: string;
}
const SYSTEM_PROMPT = `You are a title generator. Your task is to create a concise, descriptive title (5-10 words max) for a software feature based on its description.
Rules:
- Output ONLY the title, nothing else
- Keep it short and action-oriented (e.g., "Add dark mode toggle", "Fix login validation")
- Start with a verb when possible (Add, Fix, Update, Implement, Create, etc.)
- No quotes, periods, or extra formatting
- Capture the essence of the feature in a scannable way`;
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = "";
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text" && block.text) {
responseText += block.text;
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateTitleHandler(): (
req: Request,
res: Response
) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { description } = req.body as GenerateTitleRequestBody;
if (!description || typeof description !== "string") {
const response: GenerateTitleErrorResponse = {
success: false,
error: "description is required and must be a string",
};
res.status(400).json(response);
return;
}
const trimmedDescription = description.trim();
if (trimmedDescription.length === 0) {
const response: GenerateTitleErrorResponse = {
success: false,
error: "description cannot be empty",
};
res.status(400).json(response);
return;
}
logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`);
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
const stream = query({
prompt: userPrompt,
options: {
model: CLAUDE_MODEL_MAP.haiku,
systemPrompt: SYSTEM_PROMPT,
maxTurns: 1,
allowedTools: [],
permissionMode: "acceptEdits",
},
});
const title = await extractTextFromStream(stream);
if (!title || title.trim().length === 0) {
logger.warn("Received empty response from Claude");
const response: GenerateTitleErrorResponse = {
success: false,
error: "Failed to generate title - empty response",
};
res.status(500).json(response);
return;
}
logger.info(`Generated title: ${title.trim()}`);
const response: GenerateTitleSuccessResponse = {
success: true,
title: title.trim(),
};
res.json(response);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
logger.error("Title generation failed:", errorMessage);
const response: GenerateTitleErrorResponse = {
success: false,
error: errorMessage,
};
res.status(500).json(response);
}
};
}

View File

@@ -0,0 +1,29 @@
/**
* Common utilities for settings routes
*
* Provides logger and error handling utilities shared across all settings endpoints.
* Re-exports error handling helpers from the parent routes module.
*/
import { createLogger } from "../../lib/logger.js";
import {
getErrorMessage as getErrorMessageShared,
createLogError,
} from "../common.js";
/** Logger instance for settings-related operations */
export const logger = createLogger("Settings");
/**
* Extract user-friendly error message from error objects
*
* Re-exported from parent routes common module for consistency.
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*
* Convenience function for logging errors with the Settings logger.
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,67 @@
/**
* Settings routes - HTTP API for persistent file-based settings
*
* Provides endpoints for:
* - Status checking (migration readiness)
* - Global settings CRUD
* - Credentials management
* - Project-specific settings
* - localStorage to file migration
*
* All endpoints use handler factories that receive the SettingsService instance.
* Mounted at /api/settings in the main server.
*/
import { Router } from "express";
import type { SettingsService } from "../../services/settings-service.js";
import { createGetGlobalHandler } from "./routes/get-global.js";
import { createUpdateGlobalHandler } from "./routes/update-global.js";
import { createGetCredentialsHandler } from "./routes/get-credentials.js";
import { createUpdateCredentialsHandler } from "./routes/update-credentials.js";
import { createGetProjectHandler } from "./routes/get-project.js";
import { createUpdateProjectHandler } from "./routes/update-project.js";
import { createMigrateHandler } from "./routes/migrate.js";
import { createStatusHandler } from "./routes/status.js";
/**
* Create settings router with all endpoints
*
* Registers handlers for all settings-related HTTP endpoints.
* Each handler is created with the provided SettingsService instance.
*
* Endpoints:
* - GET /status - Check migration status and data availability
* - GET /global - Get global settings
* - PUT /global - Update global settings
* - GET /credentials - Get masked credentials (safe for UI)
* - PUT /credentials - Update API keys
* - POST /project - Get project settings (requires projectPath in body)
* - PUT /project - Update project settings
* - POST /migrate - Migrate settings from localStorage
*
* @param settingsService - Instance of SettingsService for file I/O
* @returns Express Router configured with all settings endpoints
*/
export function createSettingsRoutes(settingsService: SettingsService): Router {
const router = Router();
// Status endpoint (check if migration needed)
router.get("/status", createStatusHandler(settingsService));
// Global settings
router.get("/global", createGetGlobalHandler(settingsService));
router.put("/global", createUpdateGlobalHandler(settingsService));
// Credentials (separate for security)
router.get("/credentials", createGetCredentialsHandler(settingsService));
router.put("/credentials", createUpdateCredentialsHandler(settingsService));
// Project settings
router.post("/project", createGetProjectHandler(settingsService));
router.put("/project", createUpdateProjectHandler(settingsService));
// Migration from localStorage
router.post("/migrate", createMigrateHandler(settingsService));
return router;
}

View File

@@ -0,0 +1,35 @@
/**
* GET /api/settings/credentials - Get API key status (masked for security)
*
* Returns masked credentials showing which providers have keys configured.
* Each provider shows: `{ configured: boolean, masked: string }`
* Masked shows first 4 and last 4 characters for verification.
*
* Response: `{ "success": true, "credentials": { anthropic, google, openai } }`
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import { getErrorMessage, logError } from "../common.js";
/**
* Create handler factory for GET /api/settings/credentials
*
* @param settingsService - Instance of SettingsService for file I/O
* @returns Express request handler
*/
export function createGetCredentialsHandler(settingsService: SettingsService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const credentials = await settingsService.getMaskedCredentials();
res.json({
success: true,
credentials,
});
} catch (error) {
logError(error, "Get credentials failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,34 @@
/**
* GET /api/settings/global - Retrieve global user settings
*
* Returns the complete GlobalSettings object with all user preferences,
* keyboard shortcuts, AI profiles, and project history.
*
* Response: `{ "success": true, "settings": GlobalSettings }`
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import { getErrorMessage, logError } from "../common.js";
/**
* Create handler factory for GET /api/settings/global
*
* @param settingsService - Instance of SettingsService for file I/O
* @returns Express request handler
*/
export function createGetGlobalHandler(settingsService: SettingsService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const settings = await settingsService.getGlobalSettings();
res.json({
success: true,
settings,
});
} catch (error) {
logError(error, "Get global settings failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,45 @@
/**
* POST /api/settings/project - Get project-specific settings
*
* Retrieves settings overrides for a specific project. Uses POST because
* projectPath may contain special characters that don't work well in URLs.
*
* Request body: `{ projectPath: string }`
* Response: `{ "success": true, "settings": ProjectSettings }`
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import { getErrorMessage, logError } from "../common.js";
/**
* Create handler factory for POST /api/settings/project
*
* @param settingsService - Instance of SettingsService for file I/O
* @returns Express request handler
*/
export function createGetProjectHandler(settingsService: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath?: string };
if (!projectPath || typeof projectPath !== "string") {
res.status(400).json({
success: false,
error: "projectPath is required",
});
return;
}
const settings = await settingsService.getProjectSettings(projectPath);
res.json({
success: true,
settings,
});
} catch (error) {
logError(error, "Get project settings failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,88 @@
/**
* POST /api/settings/migrate - Migrate settings from localStorage to file storage
*
* Called during onboarding when UI detects localStorage data but no settings files.
* Extracts settings from various localStorage keys and writes to new file structure.
* Collects errors but continues on partial failures (graceful degradation).
*
* Request body:
* ```json
* {
* "data": {
* "automaker-storage"?: string,
* "automaker-setup"?: string,
* "worktree-panel-collapsed"?: string,
* "file-browser-recent-folders"?: string,
* "automaker:lastProjectDir"?: string
* }
* }
* ```
*
* Response:
* ```json
* {
* "success": boolean,
* "migratedGlobalSettings": boolean,
* "migratedCredentials": boolean,
* "migratedProjectCount": number,
* "errors": string[]
* }
* ```
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import { getErrorMessage, logError, logger } from "../common.js";
/**
* Create handler factory for POST /api/settings/migrate
*
* @param settingsService - Instance of SettingsService for file I/O
* @returns Express request handler
*/
export function createMigrateHandler(settingsService: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { data } = req.body as {
data?: {
"automaker-storage"?: string;
"automaker-setup"?: string;
"worktree-panel-collapsed"?: string;
"file-browser-recent-folders"?: string;
"automaker:lastProjectDir"?: string;
};
};
if (!data || typeof data !== "object") {
res.status(400).json({
success: false,
error: "data object is required containing localStorage data",
});
return;
}
logger.info("Starting settings migration from localStorage");
const result = await settingsService.migrateFromLocalStorage(data);
if (result.success) {
logger.info(
`Migration successful: ${result.migratedProjectCount} projects migrated`
);
} else {
logger.warn(`Migration completed with errors: ${result.errors.join(", ")}`);
}
res.json({
success: result.success,
migratedGlobalSettings: result.migratedGlobalSettings,
migratedCredentials: result.migratedCredentials,
migratedProjectCount: result.migratedProjectCount,
errors: result.errors,
});
} catch (error) {
logError(error, "Migration failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,47 @@
/**
* GET /api/settings/status - Get settings migration and availability status
*
* Checks which settings files exist to determine if migration from localStorage
* is needed. Used by UI during onboarding to decide whether to show migration flow.
*
* Response:
* ```json
* {
* "success": true,
* "hasGlobalSettings": boolean,
* "hasCredentials": boolean,
* "dataDir": string,
* "needsMigration": boolean
* }
* ```
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import { getErrorMessage, logError } from "../common.js";
/**
* Create handler factory for GET /api/settings/status
*
* @param settingsService - Instance of SettingsService for file I/O
* @returns Express request handler
*/
export function createStatusHandler(settingsService: SettingsService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const hasGlobalSettings = await settingsService.hasGlobalSettings();
const hasCredentials = await settingsService.hasCredentials();
res.json({
success: true,
hasGlobalSettings,
hasCredentials,
dataDir: settingsService.getDataDir(),
needsMigration: !hasGlobalSettings,
});
} catch (error) {
logError(error, "Get settings status failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,51 @@
/**
* PUT /api/settings/credentials - Update API credentials
*
* Updates API keys for Anthropic, Google, or OpenAI. Partial updates supported.
* Returns masked credentials for verification without exposing full keys.
*
* Request body: `Partial<Credentials>` (usually just apiKeys)
* Response: `{ "success": true, "credentials": { anthropic, google, openai } }`
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import type { Credentials } from "../../../types/settings.js";
import { getErrorMessage, logError } from "../common.js";
/**
* Create handler factory for PUT /api/settings/credentials
*
* @param settingsService - Instance of SettingsService for file I/O
* @returns Express request handler
*/
export function createUpdateCredentialsHandler(
settingsService: SettingsService
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const updates = req.body as Partial<Credentials>;
if (!updates || typeof updates !== "object") {
res.status(400).json({
success: false,
error: "Invalid request body - expected credentials object",
});
return;
}
await settingsService.updateCredentials(updates);
// Return masked credentials for confirmation
const masked = await settingsService.getMaskedCredentials();
res.json({
success: true,
credentials: masked,
});
} catch (error) {
logError(error, "Update credentials failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,46 @@
/**
* PUT /api/settings/global - Update global user settings
*
* Accepts partial GlobalSettings update. Fields provided are merged into
* existing settings (not replaced). Returns updated settings.
*
* Request body: `Partial<GlobalSettings>`
* Response: `{ "success": true, "settings": GlobalSettings }`
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import type { GlobalSettings } from "../../../types/settings.js";
import { getErrorMessage, logError } from "../common.js";
/**
* Create handler factory for PUT /api/settings/global
*
* @param settingsService - Instance of SettingsService for file I/O
* @returns Express request handler
*/
export function createUpdateGlobalHandler(settingsService: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const updates = req.body as Partial<GlobalSettings>;
if (!updates || typeof updates !== "object") {
res.status(400).json({
success: false,
error: "Invalid request body - expected settings object",
});
return;
}
const settings = await settingsService.updateGlobalSettings(updates);
res.json({
success: true,
settings,
});
} catch (error) {
logError(error, "Update global settings failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,60 @@
/**
* PUT /api/settings/project - Update project-specific settings
*
* Updates settings for a specific project. Partial updates supported.
* Project settings override global settings when present.
*
* Request body: `{ projectPath: string, updates: Partial<ProjectSettings> }`
* Response: `{ "success": true, "settings": ProjectSettings }`
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import type { ProjectSettings } from "../../../types/settings.js";
import { getErrorMessage, logError } from "../common.js";
/**
* Create handler factory for PUT /api/settings/project
*
* @param settingsService - Instance of SettingsService for file I/O
* @returns Express request handler
*/
export function createUpdateProjectHandler(settingsService: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, updates } = req.body as {
projectPath?: string;
updates?: Partial<ProjectSettings>;
};
if (!projectPath || typeof projectPath !== "string") {
res.status(400).json({
success: false,
error: "projectPath is required",
});
return;
}
if (!updates || typeof updates !== "object") {
res.status(400).json({
success: false,
error: "updates object is required",
});
return;
}
const settings = await settingsService.updateProjectSettings(
projectPath,
updates
);
res.json({
success: true,
settings,
});
} catch (error) {
logError(error, "Update project settings failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -14,9 +14,87 @@ import {
import { FeatureLoader } from "../../services/feature-loader.js";
const logger = createLogger("Worktree");
const execAsync = promisify(exec);
export const execAsync = promisify(exec);
const featureLoader = new FeatureLoader();
// ============================================================================
// Constants
// ============================================================================
/** Maximum allowed length for git branch names */
export const MAX_BRANCH_NAME_LENGTH = 250;
// ============================================================================
// Extended PATH configuration for Electron apps
// ============================================================================
const pathSeparator = process.platform === "win32" ? ";" : ":";
const additionalPaths: string[] = [];
if (process.platform === "win32") {
// Windows paths
if (process.env.LOCALAPPDATA) {
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
}
if (process.env.PROGRAMFILES) {
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
}
if (process.env["ProgramFiles(x86)"]) {
additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`);
}
} else {
// Unix/Mac paths
additionalPaths.push(
"/opt/homebrew/bin", // Homebrew on Apple Silicon
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
`${process.env.HOME}/.local/bin`, // pipx, other user installs
);
}
const extendedPath = [
process.env.PATH,
...additionalPaths.filter(Boolean),
].filter(Boolean).join(pathSeparator);
/**
* Environment variables with extended PATH for executing shell commands.
* Electron apps don't inherit the user's shell PATH, so we need to add
* common tool installation locations.
*/
export const execEnv = {
...process.env,
PATH: extendedPath,
};
// ============================================================================
// Validation utilities
// ============================================================================
/**
* Validate branch name to prevent command injection.
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
* We also reject shell metacharacters for safety.
*/
export function isValidBranchName(name: string): boolean {
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
}
/**
* Check if gh CLI is available on the system
*/
export async function isGhCliAvailable(): Promise<boolean> {
try {
const checkCommand = process.platform === "win32"
? "where gh"
: "command -v gh";
await execAsync(checkCommand, { env: execEnv });
return true;
} catch {
return false;
}
}
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
"chore: automaker initial commit";

View File

@@ -12,6 +12,7 @@ import { createMergeHandler } from "./routes/merge.js";
import { createCreateHandler } from "./routes/create.js";
import { createDeleteHandler } from "./routes/delete.js";
import { createCreatePRHandler } from "./routes/create-pr.js";
import { createPRInfoHandler } from "./routes/pr-info.js";
import { createCommitHandler } from "./routes/commit.js";
import { createPushHandler } from "./routes/push.js";
import { createPullHandler } from "./routes/pull.js";
@@ -40,6 +41,7 @@ export function createWorktreeRoutes(): Router {
router.post("/create", createCreateHandler());
router.post("/delete", createDeleteHandler());
router.post("/create-pr", createCreatePRHandler());
router.post("/pr-info", createPRInfoHandler());
router.post("/commit", createCommitHandler());
router.post("/push", createPushHandler());
router.post("/pull", createPullHandler());

View File

@@ -3,53 +3,22 @@
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
// This is needed because Electron apps don't inherit the user's shell PATH
const pathSeparator = process.platform === "win32" ? ";" : ":";
const additionalPaths: string[] = [];
if (process.platform === "win32") {
// Windows paths
if (process.env.LOCALAPPDATA) {
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
}
if (process.env.PROGRAMFILES) {
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
}
if (process.env["ProgramFiles(x86)"]) {
additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`);
}
} else {
// Unix/Mac paths
additionalPaths.push(
"/opt/homebrew/bin", // Homebrew on Apple Silicon
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
`${process.env.HOME}/.local/bin`, // pipx, other user installs
);
}
const extendedPath = [
process.env.PATH,
...additionalPaths.filter(Boolean),
].filter(Boolean).join(pathSeparator);
const execEnv = {
...process.env,
PATH: extendedPath,
};
import {
getErrorMessage,
logError,
execAsync,
execEnv,
isValidBranchName,
isGhCliAvailable,
} from "../common.js";
import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js";
export function createCreatePRHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
worktreePath: string;
projectPath?: string;
commitMessage?: string;
prTitle?: string;
prBody?: string;
@@ -65,6 +34,10 @@ export function createCreatePRHandler() {
return;
}
// Use projectPath if provided, otherwise derive from worktreePath
// For worktrees, projectPath is needed to store metadata in the main project's .automaker folder
const effectiveProjectPath = projectPath || worktreePath;
// Get current branch name
const { stdout: branchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
@@ -72,6 +45,15 @@ export function createCreatePRHandler() {
);
const branchName = branchOutput.trim();
// Validate branch name for security
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: "Invalid branch name contains unsafe characters",
});
return;
}
// Check for uncommitted changes
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath,
@@ -143,18 +125,8 @@ export function createCreatePRHandler() {
let browserUrl: string | null = null;
let ghCliAvailable = false;
// Check if gh CLI is available (cross-platform)
try {
const checkCommand = process.platform === "win32"
? "where gh"
: "command -v gh";
await execAsync(checkCommand, { env: execEnv });
ghCliAvailable = true;
} catch {
ghCliAvailable = false;
}
// Get repository URL for browser fallback
// Get repository URL and detect fork workflow FIRST
// This is needed for both the existing PR check and PR creation
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
@@ -180,7 +152,7 @@ export function createCreatePRHandler() {
// Try HTTPS format: https://github.com/owner/repo.git
match = line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
}
if (match) {
const [, remoteName, owner, repo] = match;
if (remoteName === "upstream") {
@@ -206,7 +178,7 @@ export function createCreatePRHandler() {
env: execEnv,
});
const url = originUrl.trim();
// Parse URL to extract owner/repo
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
@@ -220,6 +192,9 @@ export function createCreatePRHandler() {
}
}
// Check if gh CLI is available (cross-platform)
ghCliAvailable = await isGhCliAvailable();
// Construct browser URL for PR creation
if (repoUrl) {
const encodedTitle = encodeURIComponent(title);
@@ -234,32 +209,136 @@ export function createCreatePRHandler() {
}
}
let prNumber: number | undefined;
let prAlreadyExisted = false;
if (ghCliAvailable) {
// First, check if a PR already exists for this branch using gh pr list
// This is more reliable than gh pr view as it explicitly searches by branch name
// For forks, we need to use owner:branch format for the head parameter
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : "";
console.log(`[CreatePR] Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
try {
// Build gh pr create command
let prCmd = `gh pr create --base "${base}"`;
// If this is a fork (has upstream remote), specify the repo and head
if (upstreamRepo && originOwner) {
// For forks: --repo specifies where to create PR, --head specifies source
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
} else {
// Not a fork, just specify the head branch
prCmd += ` --head "${branchName}"`;
}
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
prCmd = prCmd.trim();
const { stdout: prOutput } = await execAsync(prCmd, {
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
console.log(`[CreatePR] Running: ${listCmd}`);
const { stdout: existingPrOutput } = await execAsync(listCmd, {
cwd: worktreePath,
env: execEnv,
});
prUrl = prOutput.trim();
} catch (ghError: unknown) {
// gh CLI failed
const err = ghError as { stderr?: string; message?: string };
prError = err.stderr || err.message || "PR creation failed";
console.log(`[CreatePR] gh pr list output: ${existingPrOutput}`);
const existingPrs = JSON.parse(existingPrOutput);
if (Array.isArray(existingPrs) && existingPrs.length > 0) {
const existingPr = existingPrs[0];
// PR already exists - use it and store metadata
console.log(`[CreatePR] PR already exists for branch ${branchName}: PR #${existingPr.number}`);
prUrl = existingPr.url;
prNumber = existingPr.number;
prAlreadyExisted = true;
// Store the existing PR info in metadata
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: existingPr.number,
url: existingPr.url,
title: existingPr.title || title,
state: existingPr.state || "open",
createdAt: new Date().toISOString(),
});
console.log(`[CreatePR] Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`);
} else {
console.log(`[CreatePR] No existing PR found for branch ${branchName}`);
}
} catch (listError) {
// gh pr list failed - log but continue to try creating
console.log(`[CreatePR] gh pr list failed (this is ok, will try to create):`, listError);
}
// Only create a new PR if one doesn't already exist
if (!prUrl) {
try {
// Build gh pr create command
let prCmd = `gh pr create --base "${base}"`;
// If this is a fork (has upstream remote), specify the repo and head
if (upstreamRepo && originOwner) {
// For forks: --repo specifies where to create PR, --head specifies source
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
} else {
// Not a fork, just specify the head branch
prCmd += ` --head "${branchName}"`;
}
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
prCmd = prCmd.trim();
console.log(`[CreatePR] Creating PR with command: ${prCmd}`);
const { stdout: prOutput } = await execAsync(prCmd, {
cwd: worktreePath,
env: execEnv,
});
prUrl = prOutput.trim();
console.log(`[CreatePR] PR created: ${prUrl}`);
// Extract PR number and store metadata for newly created PR
if (prUrl) {
const prMatch = prUrl.match(/\/pull\/(\d+)/);
prNumber = prMatch ? parseInt(prMatch[1], 10) : undefined;
if (prNumber) {
try {
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: prNumber,
url: prUrl,
title,
state: draft ? "draft" : "open",
createdAt: new Date().toISOString(),
});
console.log(`[CreatePR] Stored PR info for branch ${branchName}: PR #${prNumber}`);
} catch (metadataError) {
console.error("[CreatePR] Failed to store PR metadata:", metadataError);
}
}
}
} catch (ghError: unknown) {
// gh CLI failed - check if it's "already exists" error and try to fetch the PR
const err = ghError as { stderr?: string; message?: string };
const errorMessage = err.stderr || err.message || "PR creation failed";
console.log(`[CreatePR] gh pr create failed: ${errorMessage}`);
// If error indicates PR already exists, try to fetch it
if (errorMessage.toLowerCase().includes("already exists")) {
console.log(`[CreatePR] PR already exists error - trying to fetch existing PR`);
try {
const { stdout: viewOutput } = await execAsync(
`gh pr view --json number,title,url,state`,
{ cwd: worktreePath, env: execEnv }
);
const existingPr = JSON.parse(viewOutput);
if (existingPr.url) {
prUrl = existingPr.url;
prNumber = existingPr.number;
prAlreadyExisted = true;
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: existingPr.number,
url: existingPr.url,
title: existingPr.title || title,
state: existingPr.state || "open",
createdAt: new Date().toISOString(),
});
console.log(`[CreatePR] Fetched and stored existing PR: #${existingPr.number}`);
}
} catch (viewError) {
console.error("[CreatePR] Failed to fetch existing PR:", viewError);
prError = errorMessage;
}
} else {
prError = errorMessage;
}
}
}
} else {
prError = "gh_cli_not_available";
@@ -274,7 +353,9 @@ export function createCreatePRHandler() {
commitHash,
pushed: true,
prUrl,
prNumber,
prCreated: !!prUrl,
prAlreadyExisted,
prError: prError || undefined,
browserUrl: browserUrl || undefined,
ghCliAvailable,

View File

@@ -11,6 +11,7 @@ import { promisify } from "util";
import { existsSync } from "fs";
import { isGitRepo } from "@automaker/git-utils";
import { getErrorMessage, logError, normalizePath } from "../common.js";
import { readAllWorktreeMetadata, type WorktreePRInfo } from "../../../lib/worktree-metadata.js";
const execAsync = promisify(exec);
@@ -22,6 +23,7 @@ interface WorktreeInfo {
hasWorktree: boolean; // Always true for items in this list
hasChanges?: boolean;
changedFilesCount?: number;
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
}
async function getCurrentBranch(cwd: string): Promise<string> {
@@ -107,6 +109,9 @@ export function createListHandler() {
}
}
// Read all worktree metadata to get PR info
const allMetadata = await readAllWorktreeMetadata(projectPath);
// If includeDetails is requested, fetch change status for each worktree
if (includeDetails) {
for (const worktree of worktrees) {
@@ -128,6 +133,14 @@ export function createListHandler() {
}
}
// Add PR info from metadata for each worktree
for (const worktree of worktrees) {
const metadata = allMetadata.get(worktree.branch);
if (metadata?.pr) {
worktree.pr = metadata.pr;
}
}
res.json({
success: true,
worktrees,

View File

@@ -0,0 +1,269 @@
/**
* POST /pr-info endpoint - Get PR info and comments for a branch
*/
import type { Request, Response } from "express";
import {
getErrorMessage,
logError,
execAsync,
execEnv,
isValidBranchName,
isGhCliAvailable,
} from "../common.js";
export interface PRComment {
id: number;
author: string;
body: string;
path?: string;
line?: number;
createdAt: string;
isReviewComment: boolean;
}
export interface PRInfo {
number: number;
title: string;
url: string;
state: string;
author: string;
body: string;
comments: PRComment[];
reviewComments: PRComment[];
}
export function createPRInfoHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, branchName } = req.body as {
worktreePath: string;
branchName: string;
};
if (!worktreePath || !branchName) {
res.status(400).json({
success: false,
error: "worktreePath and branchName required",
});
return;
}
// Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: "Invalid branch name contains unsafe characters",
});
return;
}
// Check if gh CLI is available
const ghCliAvailable = await isGhCliAvailable();
if (!ghCliAvailable) {
res.json({
success: true,
result: {
hasPR: false,
ghCliAvailable: false,
error: "gh CLI not available",
},
});
return;
}
// Detect repository information (supports fork workflows)
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
let originRepo: string | null = null;
try {
const { stdout: remotes } = await execAsync("git remote -v", {
cwd: worktreePath,
env: execEnv,
});
const lines = remotes.split(/\r?\n/);
for (const line of lines) {
let match =
line.match(
/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/
) ||
line.match(
/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
) ||
line.match(
/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
);
if (match) {
const [, remoteName, owner, repo] = match;
if (remoteName === "upstream") {
upstreamRepo = `${owner}/${repo}`;
} else if (remoteName === "origin") {
originOwner = owner;
originRepo = repo;
}
}
}
} catch {
// Ignore remote parsing errors
}
if (!originOwner || !originRepo) {
try {
const { stdout: originUrl } = await execAsync(
"git config --get remote.origin.url",
{
cwd: worktreePath,
env: execEnv,
}
);
const match = originUrl
.trim()
.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
if (match) {
if (!originOwner) {
originOwner = match[1];
}
if (!originRepo) {
originRepo = match[2];
}
}
} catch {
// Ignore fallback errors
}
}
const targetRepo =
upstreamRepo || (originOwner && originRepo
? `${originOwner}/${originRepo}`
: null);
const repoFlag = targetRepo ? ` --repo "${targetRepo}"` : "";
const headRef =
upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
// Get PR info for the branch using gh CLI
try {
// First, find the PR associated with this branch
const listCmd = `gh pr list${repoFlag} --head "${headRef}" --json number,title,url,state,author,body --limit 1`;
const { stdout: prListOutput } = await execAsync(
listCmd,
{ cwd: worktreePath, env: execEnv }
);
const prList = JSON.parse(prListOutput);
if (prList.length === 0) {
res.json({
success: true,
result: {
hasPR: false,
ghCliAvailable: true,
},
});
return;
}
const pr = prList[0];
const prNumber = pr.number;
// Get regular PR comments (issue comments)
let comments: PRComment[] = [];
try {
const viewCmd = `gh pr view ${prNumber}${repoFlag} --json comments`;
const { stdout: commentsOutput } = await execAsync(
viewCmd,
{ cwd: worktreePath, env: execEnv }
);
const commentsData = JSON.parse(commentsOutput);
comments = (commentsData.comments || []).map((c: {
id: number;
author: { login: string };
body: string;
createdAt: string;
}) => ({
id: c.id,
author: c.author?.login || "unknown",
body: c.body,
createdAt: c.createdAt,
isReviewComment: false,
}));
} catch (error) {
console.warn("[PRInfo] Failed to fetch PR comments:", error);
}
// Get review comments (inline code comments)
let reviewComments: PRComment[] = [];
// Only fetch review comments if we have repository info
if (targetRepo) {
try {
const reviewsEndpoint = `repos/${targetRepo}/pulls/${prNumber}/comments`;
const reviewsCmd = `gh api ${reviewsEndpoint}`;
const { stdout: reviewsOutput } = await execAsync(
reviewsCmd,
{ cwd: worktreePath, env: execEnv }
);
const reviewsData = JSON.parse(reviewsOutput);
reviewComments = reviewsData.map((c: {
id: number;
user: { login: string };
body: string;
path: string;
line?: number;
original_line?: number;
created_at: string;
}) => ({
id: c.id,
author: c.user?.login || "unknown",
body: c.body,
path: c.path,
line: c.line || c.original_line,
createdAt: c.created_at,
isReviewComment: true,
}));
} catch (error) {
console.warn("[PRInfo] Failed to fetch review comments:", error);
}
} else {
console.warn("[PRInfo] Cannot fetch review comments: repository info not available");
}
const prInfo: PRInfo = {
number: prNumber,
title: pr.title,
url: pr.url,
state: pr.state,
author: pr.author?.login || "unknown",
body: pr.body || "",
comments,
reviewComments,
};
res.json({
success: true,
result: {
hasPR: true,
ghCliAvailable: true,
prInfo,
},
});
} catch (error) {
// gh CLI failed - might not be authenticated or no remote
logError(error, "Failed to get PR info");
res.json({
success: true,
result: {
hasPR: false,
ghCliAvailable: true,
error: getErrorMessage(error),
},
});
}
} catch (error) {
logError(error, "PR info handler failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,628 @@
/**
* Settings Service - Handles reading/writing settings to JSON files
*
* Provides persistent storage for:
* - Global settings (DATA_DIR/settings.json)
* - Credentials (DATA_DIR/credentials.json)
* - Per-project settings ({projectPath}/.automaker/settings.json)
*/
import fs from "fs/promises";
import path from "path";
import { createLogger } from "../lib/logger.js";
import {
getGlobalSettingsPath,
getCredentialsPath,
getProjectSettingsPath,
ensureDataDir,
ensureAutomakerDir,
} from "../lib/automaker-paths.js";
import type {
GlobalSettings,
Credentials,
ProjectSettings,
KeyboardShortcuts,
AIProfile,
ProjectRef,
TrashedProjectRef,
BoardBackgroundSettings,
WorktreeInfo,
} from "../types/settings.js";
import {
DEFAULT_GLOBAL_SETTINGS,
DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS,
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
} from "../types/settings.js";
const logger = createLogger("SettingsService");
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await fs.writeFile(tempPath, content, "utf-8");
await fs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await fs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = await fs.readFile(filePath, "utf-8");
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Check if a file exists
*/
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* SettingsService - Manages persistent storage of user settings and credentials
*
* Handles reading and writing settings to JSON files with atomic operations
* for reliability. Provides three levels of settings:
* - Global settings: shared preferences in {dataDir}/settings.json
* - Credentials: sensitive API keys in {dataDir}/credentials.json
* - Project settings: per-project overrides in {projectPath}/.automaker/settings.json
*
* All operations are atomic (write to temp file, then rename) to prevent corruption.
* Missing files are treated as empty and return defaults on read.
* Updates use deep merge for nested objects like keyboardShortcuts and apiKeys.
*/
export class SettingsService {
private dataDir: string;
/**
* Create a new SettingsService instance
*
* @param dataDir - Absolute path to global data directory (e.g., ~/.automaker)
*/
constructor(dataDir: string) {
this.dataDir = dataDir;
}
// ============================================================================
// Global Settings
// ============================================================================
/**
* Get global settings with defaults applied for any missing fields
*
* Reads from {dataDir}/settings.json. If file doesn't exist, returns defaults.
* Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward
* compatibility during schema migrations.
*
* @returns Promise resolving to complete GlobalSettings object
*/
async getGlobalSettings(): Promise<GlobalSettings> {
const settingsPath = getGlobalSettingsPath(this.dataDir);
const settings = await readJsonFile<GlobalSettings>(
settingsPath,
DEFAULT_GLOBAL_SETTINGS
);
// Apply any missing defaults (for backwards compatibility)
return {
...DEFAULT_GLOBAL_SETTINGS,
...settings,
keyboardShortcuts: {
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
...settings.keyboardShortcuts,
},
};
}
/**
* Update global settings with partial changes
*
* Performs a deep merge: nested objects like keyboardShortcuts are merged,
* not replaced. Updates are written atomically. Creates dataDir if needed.
*
* @param updates - Partial GlobalSettings to merge (only provided fields are updated)
* @returns Promise resolving to complete updated GlobalSettings
*/
async updateGlobalSettings(
updates: Partial<GlobalSettings>
): Promise<GlobalSettings> {
await ensureDataDir(this.dataDir);
const settingsPath = getGlobalSettingsPath(this.dataDir);
const current = await this.getGlobalSettings();
const updated: GlobalSettings = {
...current,
...updates,
version: SETTINGS_VERSION,
};
// Deep merge keyboard shortcuts if provided
if (updates.keyboardShortcuts) {
updated.keyboardShortcuts = {
...current.keyboardShortcuts,
...updates.keyboardShortcuts,
};
}
await atomicWriteJson(settingsPath, updated);
logger.info("Global settings updated");
return updated;
}
/**
* Check if global settings file exists
*
* Used to determine if user has previously configured settings.
*
* @returns Promise resolving to true if {dataDir}/settings.json exists
*/
async hasGlobalSettings(): Promise<boolean> {
const settingsPath = getGlobalSettingsPath(this.dataDir);
return fileExists(settingsPath);
}
// ============================================================================
// Credentials
// ============================================================================
/**
* Get credentials with defaults applied
*
* Reads from {dataDir}/credentials.json. If file doesn't exist, returns
* defaults (empty API keys). Used primarily by backend for API authentication.
* UI should use getMaskedCredentials() instead.
*
* @returns Promise resolving to complete Credentials object
*/
async getCredentials(): Promise<Credentials> {
const credentialsPath = getCredentialsPath(this.dataDir);
const credentials = await readJsonFile<Credentials>(
credentialsPath,
DEFAULT_CREDENTIALS
);
return {
...DEFAULT_CREDENTIALS,
...credentials,
apiKeys: {
...DEFAULT_CREDENTIALS.apiKeys,
...credentials.apiKeys,
},
};
}
/**
* Update credentials with partial changes
*
* Updates individual API keys. Uses deep merge for apiKeys object.
* Creates dataDir if needed. Credentials are written atomically.
* WARNING: Use only in secure contexts - keys are unencrypted.
*
* @param updates - Partial Credentials (usually just apiKeys)
* @returns Promise resolving to complete updated Credentials object
*/
async updateCredentials(
updates: Partial<Credentials>
): Promise<Credentials> {
await ensureDataDir(this.dataDir);
const credentialsPath = getCredentialsPath(this.dataDir);
const current = await this.getCredentials();
const updated: Credentials = {
...current,
...updates,
version: CREDENTIALS_VERSION,
};
// Deep merge api keys if provided
if (updates.apiKeys) {
updated.apiKeys = {
...current.apiKeys,
...updates.apiKeys,
};
}
await atomicWriteJson(credentialsPath, updated);
logger.info("Credentials updated");
return updated;
}
/**
* Get masked credentials safe for UI display
*
* Returns API keys masked for security (first 4 and last 4 chars visible).
* Use this for showing credential status in UI without exposing full keys.
* Each key includes a 'configured' boolean and masked string representation.
*
* @returns Promise resolving to masked credentials object with each provider's status
*/
async getMaskedCredentials(): Promise<{
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
}> {
const credentials = await this.getCredentials();
const maskKey = (key: string): string => {
if (!key || key.length < 8) return "";
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
};
return {
anthropic: {
configured: !!credentials.apiKeys.anthropic,
masked: maskKey(credentials.apiKeys.anthropic),
},
google: {
configured: !!credentials.apiKeys.google,
masked: maskKey(credentials.apiKeys.google),
},
openai: {
configured: !!credentials.apiKeys.openai,
masked: maskKey(credentials.apiKeys.openai),
},
};
}
/**
* Check if credentials file exists
*
* Used to determine if user has configured any API keys.
*
* @returns Promise resolving to true if {dataDir}/credentials.json exists
*/
async hasCredentials(): Promise<boolean> {
const credentialsPath = getCredentialsPath(this.dataDir);
return fileExists(credentialsPath);
}
// ============================================================================
// Project Settings
// ============================================================================
/**
* Get project-specific settings with defaults applied
*
* Reads from {projectPath}/.automaker/settings.json. If file doesn't exist,
* returns defaults. Project settings are optional - missing values fall back
* to global settings on the UI side.
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to complete ProjectSettings object
*/
async getProjectSettings(projectPath: string): Promise<ProjectSettings> {
const settingsPath = getProjectSettingsPath(projectPath);
const settings = await readJsonFile<ProjectSettings>(
settingsPath,
DEFAULT_PROJECT_SETTINGS
);
return {
...DEFAULT_PROJECT_SETTINGS,
...settings,
};
}
/**
* Update project-specific settings with partial changes
*
* Performs a deep merge on boardBackground. Creates .automaker directory
* in project if needed. Updates are written atomically.
*
* @param projectPath - Absolute path to project directory
* @param updates - Partial ProjectSettings to merge
* @returns Promise resolving to complete updated ProjectSettings
*/
async updateProjectSettings(
projectPath: string,
updates: Partial<ProjectSettings>
): Promise<ProjectSettings> {
await ensureAutomakerDir(projectPath);
const settingsPath = getProjectSettingsPath(projectPath);
const current = await this.getProjectSettings(projectPath);
const updated: ProjectSettings = {
...current,
...updates,
version: PROJECT_SETTINGS_VERSION,
};
// Deep merge board background if provided
if (updates.boardBackground) {
updated.boardBackground = {
...current.boardBackground,
...updates.boardBackground,
};
}
await atomicWriteJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`);
return updated;
}
/**
* Check if project settings file exists
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to true if {projectPath}/.automaker/settings.json exists
*/
async hasProjectSettings(projectPath: string): Promise<boolean> {
const settingsPath = getProjectSettingsPath(projectPath);
return fileExists(settingsPath);
}
// ============================================================================
// Migration
// ============================================================================
/**
* Migrate settings from localStorage to file-based storage
*
* Called during onboarding when UI detects localStorage data but no settings files.
* Extracts global settings, credentials, and per-project settings from various
* localStorage keys and writes them to the new file-based storage.
* Collects errors but continues on partial failures.
*
* @param localStorageData - Object containing localStorage key/value pairs to migrate
* @returns Promise resolving to migration result with success status and error list
*/
async migrateFromLocalStorage(localStorageData: {
"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[];
}> {
const errors: string[] = [];
let migratedGlobalSettings = false;
let migratedCredentials = false;
let migratedProjectCount = 0;
try {
// Parse the main automaker-storage
let appState: Record<string, unknown> = {};
if (localStorageData["automaker-storage"]) {
try {
const parsed = JSON.parse(localStorageData["automaker-storage"]);
appState = parsed.state || parsed;
} catch (e) {
errors.push(`Failed to parse automaker-storage: ${e}`);
}
}
// Extract global settings
const globalSettings: Partial<GlobalSettings> = {
theme: (appState.theme as GlobalSettings["theme"]) || "dark",
sidebarOpen:
appState.sidebarOpen !== undefined
? (appState.sidebarOpen as boolean)
: true,
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
kanbanCardDetailLevel:
(appState.kanbanCardDetailLevel as GlobalSettings["kanbanCardDetailLevel"]) ||
"standard",
maxConcurrency: (appState.maxConcurrency as number) || 3,
defaultSkipTests:
appState.defaultSkipTests !== undefined
? (appState.defaultSkipTests as boolean)
: true,
enableDependencyBlocking:
appState.enableDependencyBlocking !== undefined
? (appState.enableDependencyBlocking as boolean)
: true,
useWorktrees: (appState.useWorktrees as boolean) || false,
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
defaultPlanningMode:
(appState.defaultPlanningMode as GlobalSettings["defaultPlanningMode"]) ||
"skip",
defaultRequirePlanApproval:
(appState.defaultRequirePlanApproval as boolean) || false,
defaultAIProfileId:
(appState.defaultAIProfileId as string | null) || null,
muteDoneSound: (appState.muteDoneSound as boolean) || false,
enhancementModel:
(appState.enhancementModel as GlobalSettings["enhancementModel"]) ||
"sonnet",
keyboardShortcuts:
(appState.keyboardShortcuts as KeyboardShortcuts) ||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
projects: (appState.projects as ProjectRef[]) || [],
trashedProjects:
(appState.trashedProjects as TrashedProjectRef[]) || [],
projectHistory: (appState.projectHistory as string[]) || [],
projectHistoryIndex: (appState.projectHistoryIndex as number) || -1,
lastSelectedSessionByProject:
(appState.lastSelectedSessionByProject as Record<string, string>) ||
{},
};
// Add direct localStorage values
if (localStorageData["automaker:lastProjectDir"]) {
globalSettings.lastProjectDir =
localStorageData["automaker:lastProjectDir"];
}
if (localStorageData["file-browser-recent-folders"]) {
try {
globalSettings.recentFolders = JSON.parse(
localStorageData["file-browser-recent-folders"]
);
} catch {
globalSettings.recentFolders = [];
}
}
if (localStorageData["worktree-panel-collapsed"]) {
globalSettings.worktreePanelCollapsed =
localStorageData["worktree-panel-collapsed"] === "true";
}
// Save global settings
await this.updateGlobalSettings(globalSettings);
migratedGlobalSettings = true;
logger.info("Migrated global settings from localStorage");
// Extract and save credentials
if (appState.apiKeys) {
const apiKeys = appState.apiKeys as {
anthropic?: string;
google?: string;
openai?: string;
};
await this.updateCredentials({
apiKeys: {
anthropic: apiKeys.anthropic || "",
google: apiKeys.google || "",
openai: apiKeys.openai || "",
},
});
migratedCredentials = true;
logger.info("Migrated credentials from localStorage");
}
// Migrate per-project settings
const boardBackgroundByProject = appState.boardBackgroundByProject as
| Record<string, BoardBackgroundSettings>
| undefined;
const currentWorktreeByProject = appState.currentWorktreeByProject as
| Record<string, { path: string | null; branch: string }>
| undefined;
const worktreesByProject = appState.worktreesByProject as
| Record<string, WorktreeInfo[]>
| undefined;
// Get unique project paths that have per-project settings
const projectPaths = new Set<string>();
if (boardBackgroundByProject) {
Object.keys(boardBackgroundByProject).forEach((p) =>
projectPaths.add(p)
);
}
if (currentWorktreeByProject) {
Object.keys(currentWorktreeByProject).forEach((p) =>
projectPaths.add(p)
);
}
if (worktreesByProject) {
Object.keys(worktreesByProject).forEach((p) => projectPaths.add(p));
}
// Also check projects list for theme settings
const projects = (appState.projects as ProjectRef[]) || [];
for (const project of projects) {
if (project.theme) {
projectPaths.add(project.path);
}
}
// Migrate each project's settings
for (const projectPath of projectPaths) {
try {
const projectSettings: Partial<ProjectSettings> = {};
// Get theme from project object
const project = projects.find((p) => p.path === projectPath);
if (project?.theme) {
projectSettings.theme =
project.theme as ProjectSettings["theme"];
}
if (boardBackgroundByProject?.[projectPath]) {
projectSettings.boardBackground =
boardBackgroundByProject[projectPath];
}
if (currentWorktreeByProject?.[projectPath]) {
projectSettings.currentWorktree =
currentWorktreeByProject[projectPath];
}
if (worktreesByProject?.[projectPath]) {
projectSettings.worktrees = worktreesByProject[projectPath];
}
if (Object.keys(projectSettings).length > 0) {
await this.updateProjectSettings(projectPath, projectSettings);
migratedProjectCount++;
}
} catch (e) {
errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`);
}
}
logger.info(
`Migration complete: ${migratedProjectCount} projects migrated`
);
return {
success: errors.length === 0,
migratedGlobalSettings,
migratedCredentials,
migratedProjectCount,
errors,
};
} catch (error) {
logger.error("Migration failed:", error);
errors.push(`Migration failed: ${error}`);
return {
success: false,
migratedGlobalSettings,
migratedCredentials,
migratedProjectCount,
errors,
};
}
}
/**
* Get the data directory path
*
* Returns the absolute path to the directory where global settings and
* credentials are stored. Useful for logging, debugging, and validation.
*
* @returns Absolute path to data directory
*/
getDataDir(): string {
return this.dataDir;
}
}

View File

@@ -0,0 +1,428 @@
/**
* Settings Types - Shared types for file-based settings storage
*
* Defines the structure for global settings, credentials, and per-project settings
* that are persisted to disk in JSON format. These types are used by both the server
* (for file I/O via SettingsService) and the UI (for state management and sync).
*/
/**
* ThemeMode - Available color themes for the UI
*
* Includes system theme and multiple color schemes:
* - System: Respects OS dark/light mode preference
* - Light/Dark: Basic light and dark variants
* - Color Schemes: Retro, Dracula, Nord, Monokai, Tokyo Night, Solarized, Gruvbox,
* Catppuccin, OneDark, Synthwave, Red, Cream, Sunset, Gray
*/
export type ThemeMode =
| "light"
| "dark"
| "system"
| "retro"
| "dracula"
| "nord"
| "monokai"
| "tokyonight"
| "solarized"
| "gruvbox"
| "catppuccin"
| "onedark"
| "synthwave"
| "red"
| "cream"
| "sunset"
| "gray";
/** KanbanCardDetailLevel - Controls how much information is displayed on kanban cards */
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
/** AgentModel - Available Claude models for feature generation and planning */
export type AgentModel = "opus" | "sonnet" | "haiku";
/** PlanningMode - Planning levels for feature generation workflows */
export type PlanningMode = "skip" | "lite" | "spec" | "full";
/** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
/** ModelProvider - AI model provider for credentials and API key management */
export type ModelProvider = "claude";
/**
* KeyboardShortcuts - User-configurable keyboard bindings for common actions
*
* Each property maps an action to a keyboard shortcut string
* (e.g., "Ctrl+K", "Alt+N", "Shift+P")
*/
export interface KeyboardShortcuts {
/** Open board view */
board: string;
/** Open agent panel */
agent: string;
/** Open feature spec editor */
spec: string;
/** Open context files panel */
context: string;
/** Open settings */
settings: string;
/** Open AI profiles */
profiles: string;
/** Open terminal */
terminal: string;
/** Toggle sidebar visibility */
toggleSidebar: string;
/** Add new feature */
addFeature: string;
/** Add context file */
addContextFile: string;
/** Start next feature generation */
startNext: string;
/** Create new chat session */
newSession: string;
/** Open project picker */
openProject: string;
/** Open project picker (alternate) */
projectPicker: string;
/** Cycle to previous project */
cyclePrevProject: string;
/** Cycle to next project */
cycleNextProject: string;
/** Add new AI profile */
addProfile: string;
/** Split terminal right */
splitTerminalRight: string;
/** Split terminal down */
splitTerminalDown: string;
/** Close current terminal */
closeTerminal: string;
}
/**
* AIProfile - Configuration for an AI model with specific parameters
*
* Profiles can be built-in defaults or user-created. They define which model to use,
* thinking level, and other parameters for feature generation tasks.
*/
export interface AIProfile {
/** Unique identifier for the profile */
id: string;
/** Display name for the profile */
name: string;
/** User-friendly description */
description: string;
/** Which Claude model to use (opus, sonnet, haiku) */
model: AgentModel;
/** Extended thinking level for reasoning-based tasks */
thinkingLevel: ThinkingLevel;
/** Provider (currently only "claude") */
provider: ModelProvider;
/** Whether this is a built-in default profile */
isBuiltIn: boolean;
/** Optional icon identifier or emoji */
icon?: string;
}
/**
* ProjectRef - Minimal reference to a project stored in global settings
*
* Used for the projects list and project history. Full project data is loaded separately.
*/
export interface ProjectRef {
/** Unique identifier */
id: string;
/** Display name */
name: string;
/** Absolute filesystem path to project directory */
path: string;
/** ISO timestamp of last time project was opened */
lastOpened?: string;
/** Project-specific theme override (or undefined to use global) */
theme?: string;
}
/**
* TrashedProjectRef - Reference to a project in the trash/recycle bin
*
* Extends ProjectRef with deletion metadata. User can permanently delete or restore.
*/
export interface TrashedProjectRef extends ProjectRef {
/** ISO timestamp when project was moved to trash */
trashedAt: string;
/** Whether project folder was deleted from disk */
deletedFromDisk?: boolean;
}
/**
* ChatSessionRef - Minimal reference to a chat session
*
* Used for session lists and history. Full session content is stored separately.
*/
export interface ChatSessionRef {
/** Unique session identifier */
id: string;
/** User-given or AI-generated title */
title: string;
/** Project that session belongs to */
projectId: string;
/** ISO timestamp of creation */
createdAt: string;
/** ISO timestamp of last message */
updatedAt: string;
/** Whether session is archived */
archived: boolean;
}
/**
* GlobalSettings - User preferences and state stored globally in {DATA_DIR}/settings.json
*
* This is the main settings file that persists user preferences across sessions.
* Includes theme, UI state, feature defaults, keyboard shortcuts, AI profiles, and projects.
* Format: JSON with version field for migration support.
*/
export interface GlobalSettings {
/** Version number for schema migration */
version: number;
// Theme Configuration
/** Currently selected theme */
theme: ThemeMode;
// UI State Preferences
/** Whether sidebar is currently open */
sidebarOpen: boolean;
/** Whether chat history panel is open */
chatHistoryOpen: boolean;
/** How much detail to show on kanban cards */
kanbanCardDetailLevel: KanbanCardDetailLevel;
// Feature Generation Defaults
/** Max features to generate concurrently */
maxConcurrency: number;
/** Default: skip tests during feature generation */
defaultSkipTests: boolean;
/** Default: enable dependency blocking */
enableDependencyBlocking: boolean;
/** Default: use git worktrees for feature branches */
useWorktrees: boolean;
/** Default: only show AI profiles (hide other settings) */
showProfilesOnly: boolean;
/** Default: planning approach (skip/lite/spec/full) */
defaultPlanningMode: PlanningMode;
/** Default: require manual approval before generating */
defaultRequirePlanApproval: boolean;
/** ID of currently selected AI profile (null = use built-in) */
defaultAIProfileId: string | null;
// Audio Preferences
/** Mute completion notification sound */
muteDoneSound: boolean;
// AI Model Selection
/** Which model to use for feature name/description enhancement */
enhancementModel: AgentModel;
// Input Configuration
/** User's keyboard shortcut bindings */
keyboardShortcuts: KeyboardShortcuts;
// AI Profiles
/** User-created AI profiles */
aiProfiles: AIProfile[];
// Project Management
/** List of active projects */
projects: ProjectRef[];
/** Projects in trash/recycle bin */
trashedProjects: TrashedProjectRef[];
/** History of recently opened project IDs */
projectHistory: string[];
/** Current position in project history for navigation */
projectHistoryIndex: number;
// File Browser and UI Preferences
/** Last directory opened in file picker */
lastProjectDir?: string;
/** Recently accessed folders for quick access */
recentFolders: string[];
/** Whether worktree panel is collapsed in current view */
worktreePanelCollapsed: boolean;
// Session Tracking
/** Maps project path -> last selected session ID in that project */
lastSelectedSessionByProject: Record<string, string>;
}
/**
* Credentials - API keys stored in {DATA_DIR}/credentials.json
*
* Sensitive data stored separately from general settings.
* Keys should never be exposed in UI or logs.
*/
export interface Credentials {
/** Version number for schema migration */
version: number;
/** API keys for various providers */
apiKeys: {
/** Anthropic Claude API key */
anthropic: string;
/** Google API key (for embeddings or other services) */
google: string;
/** OpenAI API key (for compatibility or alternative providers) */
openai: string;
};
}
/**
* BoardBackgroundSettings - Kanban board appearance customization
*
* Controls background images, opacity, borders, and visual effects for the board.
*/
export interface BoardBackgroundSettings {
/** Path to background image file (null = no image) */
imagePath: string | null;
/** Version/timestamp of image for cache busting */
imageVersion?: number;
/** Opacity of cards (0-1) */
cardOpacity: number;
/** Opacity of columns (0-1) */
columnOpacity: number;
/** Show border around columns */
columnBorderEnabled: boolean;
/** Apply glassmorphism effect to cards */
cardGlassmorphism: boolean;
/** Show border around cards */
cardBorderEnabled: boolean;
/** Opacity of card borders (0-1) */
cardBorderOpacity: number;
/** Hide scrollbar in board view */
hideScrollbar: boolean;
}
/**
* WorktreeInfo - Information about a git worktree
*
* Tracks worktree location, branch, and dirty state for project management.
*/
export interface WorktreeInfo {
/** Absolute path to worktree directory */
path: string;
/** Branch checked out in this worktree */
branch: string;
/** Whether this is the main worktree */
isMain: boolean;
/** Whether worktree has uncommitted changes */
hasChanges?: boolean;
/** Number of files with changes */
changedFilesCount?: number;
}
/**
* ProjectSettings - Project-specific overrides stored in {projectPath}/.automaker/settings.json
*
* Allows per-project customization without affecting global settings.
* All fields are optional - missing values fall back to global settings.
*/
export interface ProjectSettings {
/** Version number for schema migration */
version: number;
// Theme Configuration (project-specific override)
/** Project theme (undefined = use global setting) */
theme?: ThemeMode;
// Worktree Management
/** Project-specific worktree preference override */
useWorktrees?: boolean;
/** Current worktree being used in this project */
currentWorktree?: { path: string | null; branch: string };
/** List of worktrees available in this project */
worktrees?: WorktreeInfo[];
// Board Customization
/** Project-specific board background settings */
boardBackground?: BoardBackgroundSettings;
// Session Tracking
/** Last chat session selected in this project */
lastSelectedSessionId?: string;
}
/**
* Default values and constants
*/
/** Default keyboard shortcut bindings */
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
board: "K",
agent: "A",
spec: "D",
context: "C",
settings: "S",
profiles: "M",
terminal: "T",
toggleSidebar: "`",
addFeature: "N",
addContextFile: "N",
startNext: "G",
newSession: "N",
openProject: "O",
projectPicker: "P",
cyclePrevProject: "Q",
cycleNextProject: "E",
addProfile: "N",
splitTerminalRight: "Alt+D",
splitTerminalDown: "Alt+S",
closeTerminal: "Alt+W",
};
/** Default global settings used when no settings file exists */
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
version: 1,
theme: "dark",
sidebarOpen: true,
chatHistoryOpen: false,
kanbanCardDetailLevel: "standard",
maxConcurrency: 3,
defaultSkipTests: true,
enableDependencyBlocking: true,
useWorktrees: false,
showProfilesOnly: false,
defaultPlanningMode: "skip",
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
muteDoneSound: false,
enhancementModel: "sonnet",
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
aiProfiles: [],
projects: [],
trashedProjects: [],
projectHistory: [],
projectHistoryIndex: -1,
lastProjectDir: undefined,
recentFolders: [],
worktreePanelCollapsed: false,
lastSelectedSessionByProject: {},
};
/** Default credentials (empty strings - user must provide API keys) */
export const DEFAULT_CREDENTIALS: Credentials = {
version: 1,
apiKeys: {
anthropic: "",
google: "",
openai: "",
},
};
/** Default project settings (empty - all settings are optional and fall back to global) */
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
version: 1,
};
/** Current version of the global settings schema */
export const SETTINGS_VERSION = 1;
/** Current version of the credentials schema */
export const CREDENTIALS_VERSION = 1;
/** Current version of the project settings schema */
export const PROJECT_SETTINGS_VERSION = 1;