From d97c4b7b57da42c212151d323af6bf75c689c658 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Mon, 19 Jan 2026 20:36:58 +0100 Subject: [PATCH 1/3] feat: unified Claude API key and profile system with z.AI, MiniMax, OpenRouter support (#600) * feat: add Claude API provider profiles for alternative endpoints Add support for managing multiple Claude-compatible API endpoints (z.AI GLM, AWS Bedrock, etc.) through provider profiles in settings. Features: - New ClaudeApiProfile type with base URL, API key, model mappings - Pre-configured z.AI GLM template with correct model names - Profile selector in Settings > Claude > API Profiles - Clean switching between profiles and direct Anthropic API - Immediate persistence to prevent data loss on restart Profile support added to all execution paths: - Agent service (chat) - Ideation service - Auto-mode service (feature agents, enhancements) - Simple query service (title generation, descriptions, etc.) - Backlog planning, commit messages, spec generation - GitHub issue validation, suggestions Environment variables set when profile is active: - ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN/API_KEY - ANTHROPIC_DEFAULT_HAIKU/SONNET/OPUS_MODEL - API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC --- apps/server/src/lib/settings-helpers.ts | 84 ++- apps/server/src/providers/claude-provider.ts | 129 +++- .../src/providers/simple-query-service.ts | 10 + .../app-spec/generate-features-from-spec.ts | 15 +- .../src/routes/app-spec/generate-spec.ts | 15 +- apps/server/src/routes/app-spec/sync-spec.ts | 14 +- .../src/routes/backlog-plan/generate-plan.ts | 15 +- .../routes/context/routes/describe-file.ts | 10 + .../routes/context/routes/describe-image.ts | 10 + .../routes/enhance-prompt/routes/enhance.ts | 19 +- .../routes/features/routes/generate-title.ts | 18 +- .../routes/github/routes/validate-issue.ts | 16 +- .../suggestions/generate-suggestions.ts | 15 +- .../routes/generate-commit-message.ts | 108 ++- apps/server/src/services/agent-service.ts | 10 + apps/server/src/services/auto-mode-service.ts | 25 + apps/server/src/services/ideation-service.ts | 20 +- apps/server/src/services/settings-service.ts | 47 ++ .../components/project-context-menu.tsx | 182 ++++- .../project-selector-with-options.tsx | 30 +- .../components/layout/sidebar/constants.ts | 31 + .../sidebar/hooks/use-project-creation.ts | 4 +- .../board-view/hooks/use-board-actions.ts | 3 +- .../shared/enhancement/enhance-with-ai.tsx | 7 +- .../config/navigation.ts | 3 +- .../hooks/use-project-settings-view.ts | 2 +- .../project-claude-section.tsx | 153 +++++ .../project-settings-view.tsx | 3 + .../api-keys/api-keys-section.tsx | 35 +- .../providers/claude-settings-tab.tsx | 5 + .../api-profiles-section.tsx | 638 ++++++++++++++++++ .../src/hooks/use-project-settings-loader.ts | 23 + apps/ui/src/hooks/use-settings-migration.ts | 22 + apps/ui/src/hooks/use-settings-sync.ts | 4 + apps/ui/src/lib/electron.ts | 15 +- apps/ui/src/lib/http-api-client.ts | 8 +- apps/ui/src/store/app-store.ts | 135 ++++ docs/UNIFIED_API_KEY_PROFILES.md | 448 ++++++++++++ libs/types/src/index.ts | 6 + libs/types/src/provider.ts | 13 +- libs/types/src/settings.ts | 157 ++++- package-lock.json | 15 +- package.json | 4 +- start-automaker.mjs | 201 ++++++ start-automaker.sh | 80 ++- 45 files changed, 2661 insertions(+), 146 deletions(-) create mode 100644 apps/ui/src/components/views/project-settings-view/project-claude-section.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/claude-settings-tab/api-profiles-section.tsx create mode 100644 docs/UNIFIED_API_KEY_PROFILES.md create mode 100644 start-automaker.mjs diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index a1bdc4e5..64f3caee 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -5,7 +5,12 @@ import type { SettingsService } from '../services/settings-service.js'; import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils'; import { createLogger } from '@automaker/utils'; -import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types'; +import type { + MCPServerConfig, + McpServerConfig, + PromptCustomization, + ClaudeApiProfile, +} from '@automaker/types'; import { mergeAutoModePrompts, mergeAgentPrompts, @@ -345,3 +350,80 @@ export async function getCustomSubagents( return Object.keys(merged).length > 0 ? merged : undefined; } + +/** Result from getActiveClaudeApiProfile */ +export interface ActiveClaudeApiProfileResult { + /** The active profile, or undefined if using direct Anthropic API */ + profile: ClaudeApiProfile | undefined; + /** Credentials for resolving 'credentials' apiKeySource */ + credentials: import('@automaker/types').Credentials | undefined; +} + +/** + * Get the active Claude API profile and credentials from settings. + * Checks project settings first for per-project overrides, then falls back to global settings. + * Returns both the profile and credentials for resolving 'credentials' apiKeySource. + * + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @param projectPath - Optional project path for per-project override + * @returns Promise resolving to object with profile and credentials + */ +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]', + projectPath?: string +): Promise { + if (!settingsService) { + return { profile: undefined, credentials: undefined }; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const credentials = await settingsService.getCredentials(); + const profiles = globalSettings.claudeApiProfiles || []; + + // Check for project-level override first + let activeProfileId: string | null | undefined; + let isProjectOverride = false; + + if (projectPath) { + const projectSettings = await settingsService.getProjectSettings(projectPath); + // undefined = use global, null = explicit no profile, string = specific profile + if (projectSettings.activeClaudeApiProfileId !== undefined) { + activeProfileId = projectSettings.activeClaudeApiProfileId; + isProjectOverride = true; + } + } + + // Fall back to global if project doesn't specify + if (activeProfileId === undefined && !isProjectOverride) { + activeProfileId = globalSettings.activeClaudeApiProfileId; + } + + // No active profile selected - use direct Anthropic API + if (!activeProfileId) { + if (isProjectOverride && activeProfileId === null) { + logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`); + } + return { profile: undefined, credentials }; + } + + // Find the active profile by ID + const activeProfile = profiles.find((p) => p.id === activeProfileId); + + if (activeProfile) { + const overrideSuffix = isProjectOverride ? ' (project override)' : ''; + logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`); + return { profile: activeProfile, credentials }; + } else { + logger.warn( + `${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API` + ); + return { profile: undefined, credentials }; + } + } catch (error) { + logger.error(`${logPrefix} Failed to load Claude API profile:`, error); + return { profile: undefined, credentials: undefined }; + } +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index f8a31d81..e4c8ad79 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -10,7 +10,12 @@ import { BaseProvider } from './base-provider.js'; import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils'; const logger = createLogger('ClaudeProvider'); -import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types'; +import { + getThinkingTokenBudget, + validateBareModelId, + type ClaudeApiProfile, + type Credentials, +} from '@automaker/types'; import type { ExecuteOptions, ProviderMessage, @@ -21,9 +26,19 @@ import type { // Explicit allowlist of environment variables to pass to the SDK. // Only these vars are passed - nothing else from process.env leaks through. const ALLOWED_ENV_VARS = [ + // Authentication 'ANTHROPIC_API_KEY', - 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', + // Endpoint configuration + 'ANTHROPIC_BASE_URL', + 'API_TIMEOUT_MS', + // Model mappings + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + // Traffic control + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', + // System vars (always from process.env) 'PATH', 'HOME', 'SHELL', @@ -33,16 +48,114 @@ const ALLOWED_ENV_VARS = [ 'LC_ALL', ]; +// System vars are always passed from process.env regardless of profile +const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL']; + /** - * Build environment for the SDK with only explicitly allowed variables + * Build environment for the SDK with only explicitly allowed variables. + * When a profile is provided, uses profile configuration (clean switch - don't inherit from process.env). + * When no profile is provided, uses direct Anthropic API settings from process.env. + * + * @param profile - Optional Claude API profile for alternative endpoint configuration + * @param credentials - Optional credentials object for resolving 'credentials' apiKeySource */ -function buildEnv(): Record { +function buildEnv( + profile?: ClaudeApiProfile, + credentials?: Credentials +): Record { const env: Record = {}; - for (const key of ALLOWED_ENV_VARS) { + + if (profile) { + // Use profile configuration (clean switch - don't inherit non-system vars from process.env) + logger.debug('Building environment from Claude API profile:', { + name: profile.name, + apiKeySource: profile.apiKeySource ?? 'inline', + }); + + // Resolve API key based on source strategy + let apiKey: string | undefined; + const source = profile.apiKeySource ?? 'inline'; // Default to inline for backwards compat + + switch (source) { + case 'inline': + apiKey = profile.apiKey; + break; + case 'env': + apiKey = process.env.ANTHROPIC_API_KEY; + break; + case 'credentials': + apiKey = credentials?.apiKeys?.anthropic; + break; + } + + // Warn if no API key found + if (!apiKey) { + logger.warn(`No API key found for profile "${profile.name}" with source "${source}"`); + } + + // Authentication + if (profile.useAuthToken) { + env['ANTHROPIC_AUTH_TOKEN'] = apiKey; + } else { + env['ANTHROPIC_API_KEY'] = apiKey; + } + + // Endpoint configuration + env['ANTHROPIC_BASE_URL'] = profile.baseUrl; + + if (profile.timeoutMs) { + env['API_TIMEOUT_MS'] = String(profile.timeoutMs); + } + + // Model mappings + if (profile.modelMappings?.haiku) { + env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku; + } + if (profile.modelMappings?.sonnet) { + env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet; + } + if (profile.modelMappings?.opus) { + env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus; + } + + // Traffic control + if (profile.disableNonessentialTraffic) { + env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1'; + } + } else { + // Use direct Anthropic API - pass through credentials or environment variables + // This supports: + // 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env + // 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically) + // 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility) + // + // Priority: credentials file (UI settings) -> environment variable + // Note: Only auth and endpoint vars are passed. Model mappings and traffic + // control are NOT passed (those require a profile for explicit configuration). + if (credentials?.apiKeys?.anthropic) { + env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic; + } else if (process.env.ANTHROPIC_API_KEY) { + env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY; + } + // If using Claude Max plan via CLI auth, the SDK handles auth automatically + // when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here + // unless it was explicitly set in process.env (rare edge case). + if (process.env.ANTHROPIC_AUTH_TOKEN) { + env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN; + } + // Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility) + if (process.env.ANTHROPIC_BASE_URL) { + env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL; + } + } + + // Always add system vars from process.env + for (const key of SYSTEM_ENV_VARS) { if (process.env[key]) { env[key] = process.env[key]; } } + return env; } @@ -70,6 +183,8 @@ export class ClaudeProvider extends BaseProvider { conversationHistory, sdkSessionId, thinkingLevel, + claudeApiProfile, + credentials, } = options; // Convert thinking level to token budget @@ -82,7 +197,9 @@ export class ClaudeProvider extends BaseProvider { maxTurns, cwd, // Pass only explicitly allowed environment variables to SDK - env: buildEnv(), + // When a profile is active, uses profile settings (clean switch) + // When no profile, uses direct Anthropic API (from process.env or CLI OAuth) + env: buildEnv(claudeApiProfile, credentials), // Pass through allowedTools if provided by caller (decided by sdk-options.ts) ...(allowedTools && { allowedTools }), // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts index 5882b96f..6ffbed0f 100644 --- a/apps/server/src/providers/simple-query-service.ts +++ b/apps/server/src/providers/simple-query-service.ts @@ -20,6 +20,8 @@ import type { ContentBlock, ThinkingLevel, ReasoningEffort, + ClaudeApiProfile, + Credentials, } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types'; @@ -54,6 +56,10 @@ export interface SimpleQueryOptions { readOnly?: boolean; /** Setting sources for CLAUDE.md loading */ settingSources?: Array<'user' | 'project' | 'local'>; + /** Active Claude API profile for alternative endpoint configuration */ + claudeApiProfile?: ClaudeApiProfile; + /** Credentials for resolving 'credentials' apiKeySource in Claude API profiles */ + credentials?: Credentials; } /** @@ -125,6 +131,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise { logger.debug(`Feature text block received (${text.length} chars)`); events.emit('spec-regeneration:event', { diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 4fa3d11a..0de21cf5 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -16,7 +16,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; const logger = createLogger('SpecRegeneration'); @@ -100,6 +104,13 @@ ${prompts.appSpec.structuredSpecInstructions}`; logger.info('Using model:', model); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[SpecRegeneration]', + projectPath + ); + let responseText = ''; let structuredOutput: SpecOutput | null = null; @@ -132,6 +143,8 @@ Your entire response should be valid JSON starting with { and ending with }. No thinkingLevel, readOnly: true, // Spec generation only reads code, we write the spec ourselves settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/app-spec/sync-spec.ts b/apps/server/src/routes/app-spec/sync-spec.ts index 98352855..aabeebf2 100644 --- a/apps/server/src/routes/app-spec/sync-spec.ts +++ b/apps/server/src/routes/app-spec/sync-spec.ts @@ -15,7 +15,10 @@ import { resolvePhaseModel } from '@automaker/model-resolver'; import { streamingQuery } from '../../providers/simple-query-service.js'; import { getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; import { FeatureLoader } from '../../services/feature-loader.js'; import { extractImplementedFeatures, @@ -157,6 +160,13 @@ export async function syncSpec( settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[SpecSync]', + projectPath + ); + // Use AI to analyze tech stack const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack. @@ -185,6 +195,8 @@ Return ONLY this JSON format, no other text: thinkingLevel, readOnly: true, settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource onText: (text) => { logger.debug(`Tech analysis text: ${text.substring(0, 100)}`); }, diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index e96ce8ea..04dc3b57 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -25,7 +25,11 @@ import { saveBacklogPlan, } from './common.js'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; const featureLoader = new FeatureLoader(); @@ -161,6 +165,13 @@ ${userPrompt}`; finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt } + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[BacklogPlan]', + projectPath + ); + // Execute the query const stream = provider.executeQuery({ prompt: finalPrompt, @@ -173,6 +184,8 @@ ${userPrompt}`; settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined, readOnly: true, // Plan generation only generates text, doesn't write files thinkingLevel, // Pass thinking level for extended thinking + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); let responseText = ''; diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index 5b1fc6ca..0fd3c349 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, + getActiveClaudeApiProfile, } from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeFile'); @@ -165,6 +166,13 @@ ${contentToAnalyze}`; logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[DescribeFile]', + cwd + ); + // Use simpleQuery - provider abstraction handles routing to correct provider const result = await simpleQuery({ prompt, @@ -175,6 +183,8 @@ ${contentToAnalyze}`; thinkingLevel, readOnly: true, // File description only reads, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const description = result.text; diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index 70f9f7dc..0c05bc2a 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, + getActiveClaudeApiProfile, } from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeImage'); @@ -284,6 +285,13 @@ export function createDescribeImageHandler( // Get customized prompts from settings const prompts = await getPromptCustomization(settingsService, '[DescribeImage]'); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[DescribeImage]', + cwd + ); + // Build the instruction text from centralized prompts const instructionText = prompts.contextDescription.describeImagePrompt; @@ -325,6 +333,8 @@ export function createDescribeImageHandler( thinkingLevel, readOnly: true, // Image description only reads, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`); diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 5861b418..2fe0f669 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -12,7 +12,10 @@ import { resolveModelString } from '@automaker/model-resolver'; import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types'; import { simpleQuery } from '../../../providers/simple-query-service.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getPromptCustomization } from '../../../lib/settings-helpers.js'; +import { + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../../lib/settings-helpers.js'; import { buildUserPrompt, isValidEnhancementMode, @@ -33,6 +36,8 @@ interface EnhanceRequestBody { model?: string; /** Optional thinking level for Claude models */ thinkingLevel?: ThinkingLevel; + /** Optional project path for per-project Claude API profile */ + projectPath?: string; } /** @@ -62,7 +67,7 @@ export function createEnhanceHandler( ): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { - const { originalText, enhancementMode, model, thinkingLevel } = + const { originalText, enhancementMode, model, thinkingLevel, projectPath } = req.body as EnhanceRequestBody; // Validate required fields @@ -126,6 +131,14 @@ export function createEnhanceHandler( logger.debug(`Using model: ${resolvedModel}`); + // Get active Claude API profile for alternative endpoint configuration + // Uses project-specific profile if projectPath provided, otherwise global + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[EnhancePrompt]', + projectPath + ); + // Use simpleQuery - provider abstraction handles routing to correct provider // The system prompt is combined with user prompt since some providers // don't have a separate system prompt concept @@ -137,6 +150,8 @@ export function createEnhanceHandler( allowedTools: [], thinkingLevel, readOnly: true, // Prompt enhancement only generates text, doesn't write files + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const enhancedText = result.text; diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index e7603eb8..d6519940 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -10,12 +10,16 @@ import { createLogger } from '@automaker/utils'; import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; import { simpleQuery } from '../../../providers/simple-query-service.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getPromptCustomization } from '../../../lib/settings-helpers.js'; +import { + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateTitle'); interface GenerateTitleRequestBody { description: string; + projectPath?: string; } interface GenerateTitleSuccessResponse { @@ -33,7 +37,7 @@ export function createGenerateTitleHandler( ): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { - const { description } = req.body as GenerateTitleRequestBody; + const { description, projectPath } = req.body as GenerateTitleRequestBody; if (!description || typeof description !== 'string') { const response: GenerateTitleErrorResponse = { @@ -60,6 +64,14 @@ export function createGenerateTitleHandler( const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]'); const systemPrompt = prompts.titleGeneration.systemPrompt; + // Get active Claude API profile for alternative endpoint configuration + // Uses project-specific profile if projectPath provided, otherwise global + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[GenerateTitle]', + projectPath + ); + const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; // Use simpleQuery - provider abstraction handles all the streaming/extraction @@ -69,6 +81,8 @@ export function createGenerateTitleHandler( cwd: process.cwd(), maxTurns: 1, allowedTools: [], + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const title = result.text; diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index e7d83d99..699b7e46 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -34,7 +34,11 @@ import { ValidationComment, ValidationLinkedPR, } from './validation-schema.js'; -import { getPromptCustomization } from '../../../lib/settings-helpers.js'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + getActiveClaudeApiProfile, +} from '../../../lib/settings-helpers.js'; import { trySetValidationRunning, clearValidationStatus, @@ -43,7 +47,6 @@ import { logger, } from './validation-common.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; /** * Request body for issue validation @@ -166,6 +169,13 @@ ${basePrompt}`; logger.info(`Using model: ${model}`); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[IssueValidation]', + projectPath + ); + // Use streamingQuery with event callbacks const result = await streamingQuery({ prompt: finalPrompt, @@ -177,6 +187,8 @@ ${basePrompt}`; reasoningEffort: effectiveReasoningEffort, readOnly: true, // Issue validation only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 08a3628b..7a21af6f 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -15,7 +15,11 @@ import { FeatureLoader } from '../../services/feature-loader.js'; import { getAppSpecPath } from '@automaker/platform'; import * as secureFs from '../../lib/secure-fs.js'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; const logger = createLogger('Suggestions'); @@ -192,6 +196,13 @@ ${prompts.suggestions.baseTemplate}`; logger.info('[Suggestions] Using model:', model); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[Suggestions]', + projectPath + ); + let responseText = ''; // Determine if we should use structured output (Claude supports it, Cursor doesn't) @@ -223,6 +234,8 @@ Your entire response should be valid JSON starting with { and ending with }. No thinkingLevel, readOnly: true, // Suggestions only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts index a450659f..1b504f6f 100644 --- a/apps/server/src/routes/worktree/routes/generate-commit-message.ts +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -10,7 +10,6 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { existsSync } from 'fs'; import { join } from 'path'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; @@ -18,6 +17,7 @@ import { mergeCommitMessagePrompts } from '@automaker/prompts'; import { ProviderFactory } from '../../../providers/provider-factory.js'; import type { SettingsService } from '../../../services/settings-service.js'; import { getErrorMessage, logError } from '../common.js'; +import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateCommitMessage'); const execAsync = promisify(exec); @@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse { error: string; } -async function extractTextFromStream( - stream: AsyncIterable<{ - type: string; - subtype?: string; - result?: string; - message?: { - content?: Array<{ type: string; text?: string }>; - }; - }> -): Promise { - 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 createGenerateCommitMessageHandler( settingsService?: SettingsService ): (req: Request, res: Response) => Promise { @@ -195,57 +168,54 @@ export function createGenerateCommitMessageHandler( // Get the effective system prompt (custom or default) const systemPrompt = await getSystemPrompt(settingsService); - let message: string; + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[GenerateCommitMessage]', + worktreePath + ); - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info(`Using Cursor provider for model: ${model}`); + // Get provider for the model type + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); - const provider = ProviderFactory.getProviderForModel(model); - const bareModel = stripProviderPrefix(model); + // For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation + const effectivePrompt = isCursorModel(model) + ? `${systemPrompt}\n\n${userPrompt}` + : userPrompt; + const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt; - const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`; + logger.info(`Using ${provider.getName()} provider for model: ${model}`); - let responseText = ''; - const cursorStream = provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: worktreePath, - maxTurns: 1, - allowedTools: [], - readOnly: true, - }); + let responseText = ''; + const stream = provider.executeQuery({ + prompt: effectivePrompt, + model: bareModel, + cwd: worktreePath, + systemPrompt: effectiveSystemPrompt, + maxTurns: 1, + allowedTools: [], + readOnly: true, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + }); - // Wrap with timeout to prevent indefinite hangs - for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } + // Wrap with timeout to prevent indefinite hangs + for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) { + 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' && msg.result) { + // Use result if available (some providers return final text here) + responseText = msg.result; } - - message = responseText.trim(); - } else { - // Use Claude SDK for Claude models - const stream = query({ - prompt: userPrompt, - options: { - model, - systemPrompt, - maxTurns: 1, - allowedTools: [], - permissionMode: 'default', - }, - }); - - // Wrap with timeout to prevent indefinite hangs - message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS)); } + const message = responseText.trim(); + if (!message || message.trim().length === 0) { logger.warn('Received empty response from model'); const response: GenerateCommitMessageErrorResponse = { diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 359719d3..0b89a2bd 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -29,6 +29,7 @@ import { getSkillsConfiguration, getSubagentsConfiguration, getCustomSubagents, + getActiveClaudeApiProfile, } from '../lib/settings-helpers.js'; interface Message { @@ -274,6 +275,13 @@ export class AgentService { ? await getCustomSubagents(this.settingsService, effectiveWorkDir) : undefined; + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AgentService]', + effectiveWorkDir + ); + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Use the user's message as task context for smart memory selection const contextResult = await loadContextFiles({ @@ -378,6 +386,8 @@ export class AgentService { agents: customSubagents, // Pass custom subagents for task delegation thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; // Build prompt content with images diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 59af7872..bf19aa9e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -63,6 +63,7 @@ import { filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, + getActiveClaudeApiProfile, } from '../lib/settings-helpers.js'; import { getNotificationService } from './notification-service.js'; @@ -2057,6 +2058,13 @@ Format your response as a structured markdown document.`; thinkingLevel: analysisThinkingLevel, }); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AutoMode]', + projectPath + ); + const options: ExecuteOptions = { prompt, model: sdkOptions.model ?? analysisModel, @@ -2066,6 +2074,8 @@ Format your response as a structured markdown document.`; abortController, settingSources: sdkOptions.settingSources, thinkingLevel: analysisThinkingLevel, // Pass thinking level + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(options); @@ -2934,6 +2944,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ); } + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AutoMode]', + finalProjectPath + ); + const executeOptions: ExecuteOptions = { prompt: promptContent, model: bareModel, @@ -2945,6 +2962,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. settingSources: sdkOptions.settingSources, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; // Execute via provider @@ -3247,6 +3266,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); let revisionText = ''; @@ -3392,6 +3413,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); let taskOutput = ''; @@ -3486,6 +3509,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); for await (const msg of continuationStream) { diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 4ef3d8a8..bcb469b1 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { resolveModelString } from '@automaker/model-resolver'; import { stripProviderPrefix } from '@automaker/types'; -import { getPromptCustomization } from '../lib/settings-helpers.js'; +import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js'; const logger = createLogger('IdeationService'); @@ -223,6 +223,13 @@ export class IdeationService { // Strip provider prefix - providers need bare model IDs const bareModel = stripProviderPrefix(modelId); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[IdeationService]', + projectPath + ); + const executeOptions: ExecuteOptions = { prompt: message, model: bareModel, @@ -232,6 +239,8 @@ export class IdeationService { maxTurns: 1, // Single turn for ideation abortController: activeSession.abortController!, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(executeOptions); @@ -678,6 +687,13 @@ export class IdeationService { // Strip provider prefix - providers need bare model IDs const bareModel = stripProviderPrefix(modelId); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[IdeationService]', + projectPath + ); + const executeOptions: ExecuteOptions = { prompt: prompt.prompt, model: bareModel, @@ -688,6 +704,8 @@ export class IdeationService { // Disable all tools - we just want text generation, not codebase analysis allowedTools: [], abortController: new AbortController(), + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(executeOptions); diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 5b9f81cb..61afa057 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -166,6 +166,41 @@ export class SettingsService { needsSave = true; } + // Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users + // If user has an Anthropic API key in credentials but no profiles, create a + // "Direct Anthropic" profile that references the credentials and set it as active. + if (storedVersion < 5) { + try { + const credentials = await this.getCredentials(); + const hasAnthropicKey = !!credentials.apiKeys?.anthropic; + const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0; + const hasNoActiveProfile = !result.activeClaudeApiProfileId; + + if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { + const directAnthropicProfile = { + id: `profile-${Date.now()}-direct-anthropic`, + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + apiKeySource: 'credentials' as const, + useAuthToken: false, + }; + + result.claudeApiProfiles = [directAnthropicProfile]; + result.activeClaudeApiProfileId = directAnthropicProfile.id; + + logger.info( + 'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials' + ); + } + } catch (error) { + logger.warn( + 'Migration v4->v5: Could not check credentials for auto-profile creation:', + error + ); + } + needsSave = true; + } + // Update version if any migration occurred if (needsSave) { result.version = SETTINGS_VERSION; @@ -372,6 +407,7 @@ export class SettingsService { ignoreEmptyArrayOverwrite('recentFolders'); ignoreEmptyArrayOverwrite('mcpServers'); ignoreEmptyArrayOverwrite('enabledCursorModels'); + ignoreEmptyArrayOverwrite('claudeApiProfiles'); // Empty object overwrite guard if ( @@ -597,6 +633,17 @@ export class SettingsService { }; } + // Handle activeClaudeApiProfileId special cases: + // - "__USE_GLOBAL__" marker means delete the key (use global setting) + // - null means explicit "Direct Anthropic API" + // - string means specific profile ID + if ( + 'activeClaudeApiProfileId' in updates && + updates.activeClaudeApiProfileId === '__USE_GLOBAL__' + ) { + delete updated.activeClaudeApiProfileId; + } + await writeSettingsJson(settingsPath, updated); logger.info(`Project settings updated for ${projectPath}`); diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx index af63af32..0df4ab8c 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, memo, useCallback } from 'react'; +import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; import { toast } from 'sonner'; @@ -6,35 +6,67 @@ import { cn } from '@/lib/utils'; import { type ThemeMode, useAppStore } from '@/store/app-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import type { Project } from '@/lib/electron'; -import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants'; +import { + PROJECT_DARK_THEMES, + PROJECT_LIGHT_THEMES, + THEME_SUBMENU_CONSTANTS, +} from '@/components/layout/sidebar/constants'; import { useThemePreview } from '@/components/layout/sidebar/hooks'; -// Constant for "use global theme" option +/** + * Constant representing the "use global theme" option. + * An empty string is used to indicate that no project-specific theme is set. + */ const USE_GLOBAL_THEME = '' as const; -// Constants for z-index values +/** + * Z-index values for context menu layering. + * Ensures proper stacking order when menus overlap. + */ const Z_INDEX = { + /** Base z-index for the main context menu */ CONTEXT_MENU: 100, + /** Higher z-index for theme submenu to appear above parent menu */ THEME_SUBMENU: 101, } as const; -// Theme option type - using ThemeMode for type safety +/** + * Represents a selectable theme option in the theme submenu. + * Uses ThemeMode from app-store for type safety. + */ interface ThemeOption { + /** The theme mode value (e.g., 'dark', 'light', 'dracula') */ value: ThemeMode; + /** Display label for the theme option */ label: string; + /** Lucide icon component to display alongside the label */ icon: LucideIcon; + /** CSS color value for the icon */ color: string; } -// Reusable theme button component to avoid duplication (DRY principle) +/** + * Props for the ThemeButton component. + * Defines the interface for rendering individual theme selection buttons. + */ interface ThemeButtonProps { + /** The theme option data to display */ option: ThemeOption; + /** Whether this theme is currently selected */ isSelected: boolean; + /** Handler for pointer enter events (used for preview) */ onPointerEnter: () => void; + /** Handler for pointer leave events (used to clear preview) */ onPointerLeave: (e: React.PointerEvent) => void; + /** Handler for click events (used to select theme) */ onClick: () => void; } +/** + * A reusable button component for individual theme options. + * Implements hover preview and selection functionality. + * Memoized to prevent unnecessary re-renders when parent state changes. + */ const ThemeButton = memo(function ThemeButton({ option, isSelected, @@ -63,17 +95,33 @@ const ThemeButton = memo(function ThemeButton({ ); }); -// Reusable theme column component +/** + * Props for the ThemeColumn component. + * Defines the interface for rendering a column of related theme options (e.g., dark or light themes). + */ interface ThemeColumnProps { + /** Column header title (e.g., "Dark", "Light") */ title: string; + /** Icon to display in the column header */ icon: LucideIcon; + /** Array of theme options to display in this column */ themes: ThemeOption[]; + /** Currently selected theme value, or null if using global theme */ selectedTheme: ThemeMode | null; + /** Handler called when user hovers over a theme option for preview */ onPreviewEnter: (value: ThemeMode) => void; + /** Handler called when user stops hovering over a theme option */ onPreviewLeave: (e: React.PointerEvent) => void; + /** Handler called when user clicks to select a theme */ onSelect: (value: ThemeMode) => void; } +/** + * A reusable column component for displaying themed options. + * Renders a group of related themes (e.g., all dark themes or all light themes) + * with a header and scrollable list of ThemeButton components. + * Memoized to prevent unnecessary re-renders. + */ const ThemeColumn = memo(function ThemeColumn({ title, icon: Icon, @@ -105,13 +153,36 @@ const ThemeColumn = memo(function ThemeColumn({ ); }); +/** + * Props for the ProjectContextMenu component. + * Defines the interface for the project right-click context menu. + */ interface ProjectContextMenuProps { + /** The project this context menu is for */ project: Project; + /** Screen coordinates where the context menu should appear */ position: { x: number; y: number }; + /** Callback to close the context menu */ onClose: () => void; + /** Callback when user selects "Edit Name & Icon" option */ onEdit: (project: Project) => void; } +/** + * A context menu component for project-specific actions. + * + * Provides options for: + * - Editing project name and icon + * - Setting project-specific theme (with live preview on hover) + * - Removing project from the workspace + * + * Features viewport-aware positioning for the theme submenu to prevent + * overflow, and implements delayed hover handling to improve UX when + * navigating between the trigger button and submenu. + * + * @param props - Component props + * @returns The rendered context menu or null if not visible + */ export function ProjectContextMenu({ project, position, @@ -130,9 +201,82 @@ export function ProjectContextMenu({ const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); const [removeConfirmed, setRemoveConfirmed] = useState(false); const themeSubmenuRef = useRef(null); + const closeTimeoutRef = useRef | null>(null); const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + // Handler to open theme submenu and cancel any pending close + const handleThemeMenuEnter = useCallback(() => { + // Cancel any pending close timeout + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + setShowThemeSubmenu(true); + }, []); + + // Handler to close theme submenu with a small delay + // This prevents the submenu from closing when mouse crosses the gap between trigger and submenu + const handleThemeMenuLeave = useCallback(() => { + // Add a small delay before closing to allow mouse to reach submenu + closeTimeoutRef.current = setTimeout(() => { + setShowThemeSubmenu(false); + setPreviewTheme(null); + }, 100); // 100ms delay is enough to cross the gap + }, [setPreviewTheme]); + + /** + * Calculates theme submenu position to prevent viewport overflow. + * + * This memoized calculation determines the optimal vertical position and maximum + * height for the theme submenu based on the current viewport dimensions and + * the trigger button's position. + * + * @returns Object containing: + * - top: Vertical offset from default position (negative values shift submenu up) + * - maxHeight: Maximum height constraint to prevent overflow with scrolling + */ + const submenuPosition = useMemo(() => { + const { ESTIMATED_SUBMENU_HEIGHT, COLLISION_PADDING, THEME_BUTTON_OFFSET } = + THEME_SUBMENU_CONSTANTS; + + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800; + + // Calculate where the submenu's bottom edge would be if positioned normally + const submenuBottomY = position.y + THEME_BUTTON_OFFSET + ESTIMATED_SUBMENU_HEIGHT; + + // Check if submenu would overflow bottom of viewport + const wouldOverflowBottom = submenuBottomY > viewportHeight - COLLISION_PADDING; + + // If it would overflow, calculate how much to shift it up + if (wouldOverflowBottom) { + // Calculate the offset needed to align submenu bottom with viewport bottom minus padding + const overflowAmount = submenuBottomY - (viewportHeight - COLLISION_PADDING); + return { + top: -overflowAmount, + maxHeight: Math.min(ESTIMATED_SUBMENU_HEIGHT, viewportHeight - COLLISION_PADDING * 2), + }; + } + + // Default: submenu opens at top of parent (aligned with the theme button) + return { + top: 0, + maxHeight: Math.min( + ESTIMATED_SUBMENU_HEIGHT, + viewportHeight - position.y - THEME_BUTTON_OFFSET - COLLISION_PADDING + ), + }; + }, [position.y]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + useEffect(() => { const handleClickOutside = (event: globalThis.MouseEvent) => { // Don't close if a confirmation dialog is open (dialog is in a portal) @@ -242,11 +386,8 @@ export function ProjectContextMenu({ {/* Theme Submenu Trigger */}
setShowThemeSubmenu(true)} - onMouseLeave={() => { - setShowThemeSubmenu(false); - setPreviewTheme(null); - }} + onMouseEnter={handleThemeMenuEnter} + onMouseLeave={handleThemeMenuLeave} > + + + handleOpenAddDialog()}> + + Custom Profile + + + {CLAUDE_API_PROFILE_TEMPLATES.map((template) => ( + handleOpenAddDialog(template.name)} + > + + {template.name} + + ))} + + +
+ + {/* Content */} +
+ {/* Active Profile Selector */} +
+ + +

+ {activeClaudeApiProfileId + ? 'Using custom API endpoint' + : 'Using direct Anthropic API (API key or Claude Max plan)'} +

+
+ + {/* Profile List */} + {claudeApiProfiles.length === 0 ? ( +
+ +

No API profiles configured

+

+ Add a profile to use alternative Claude-compatible endpoints +

+
+ ) : ( +
+ {claudeApiProfiles.map((profile) => ( + handleOpenEditDialog(profile)} + onDelete={() => setDeleteConfirmId(profile.id)} + onSetActive={() => setActiveClaudeApiProfile(profile.id)} + /> + ))} +
+ )} +
+ + {/* Add/Edit Dialog */} + + + + {editingProfileId ? 'Edit API Profile' : 'Add API Profile'} + + Configure a Claude-compatible API endpoint. API keys are stored locally. + + + +
+ {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., z.AI GLM" + className={isDuplicateName ? 'border-destructive' : ''} + /> + {isDuplicateName && ( +

A profile with this name already exists

+ )} +
+ + {/* Base URL */} +
+ + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.example.com/v1" + /> +
+ + {/* API Key Source */} +
+ + + {formData.apiKeySource === 'credentials' && ( +

+ Will use the Anthropic key from Settings → API Keys +

+ )} + {formData.apiKeySource === 'env' && ( +

+ Will use ANTHROPIC_API_KEY environment variable +

+ )} +
+ + {/* API Key (only shown for inline source) */} + {formData.apiKeySource === 'inline' && ( +
+ +
+ setFormData({ ...formData, apiKey: e.target.value })} + placeholder="Enter API key" + className="pr-10" + /> + +
+ {currentTemplate?.apiKeyUrl && ( + + Get API Key from {currentTemplate.name} + + )} +
+ )} + + {/* Use Auth Token */} +
+
+ +

+ Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY +

+
+ setFormData({ ...formData, useAuthToken: checked })} + /> +
+ + {/* Timeout */} +
+ + setFormData({ ...formData, timeoutMs: e.target.value })} + placeholder="Optional, e.g., 3000000" + /> +
+ + {/* Model Mappings */} +
+ +

+ Map Claude model aliases to provider-specific model names +

+
+
+ + + setFormData({ + ...formData, + modelMappings: { ...formData.modelMappings, haiku: e.target.value }, + }) + } + placeholder="e.g., GLM-4.5-Flash" + className="text-xs" + /> +
+
+ + + setFormData({ + ...formData, + modelMappings: { ...formData.modelMappings, sonnet: e.target.value }, + }) + } + placeholder="e.g., glm-4.7" + className="text-xs" + /> +
+
+ + + setFormData({ + ...formData, + modelMappings: { ...formData.modelMappings, opus: e.target.value }, + }) + } + placeholder="e.g., glm-4.7" + className="text-xs" + /> +
+
+
+ + {/* Disable Non-essential Traffic */} +
+
+ +

+ Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +

+
+ + setFormData({ ...formData, disableNonessentialTraffic: checked }) + } + /> +
+
+ + + + + +
+
+ + {/* Delete Confirmation Dialog */} + !open && setDeleteConfirmId(null)}> + + + Delete Profile? + + This will permanently delete the API profile. If this profile is currently active, you + will be switched to direct Anthropic API. + + + + + + + + + + ); +} + +interface ProfileCardProps { + profile: ClaudeApiProfile; + isActive: boolean; + onEdit: () => void; + onDelete: () => void; + onSetActive: () => void; +} + +function ProfileCard({ profile, isActive, onEdit, onDelete, onSetActive }: ProfileCardProps) { + return ( +
+
+
+
+

{profile.name}

+ {isActive && ( + + Active + + )} +
+

{profile.baseUrl}

+
+ Key: {maskApiKey(profile.apiKey)} + {profile.useAuthToken && Auth Token} + {profile.timeoutMs && Timeout: {(profile.timeoutMs / 1000).toFixed(0)}s} +
+
+ + + + + + + {!isActive && ( + + + Set Active + + )} + + + Edit + + + + + Delete + + + +
+
+ ); +} diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index da0ef594..53c906e1 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -23,6 +23,7 @@ export function useProjectSettingsLoader() { const setAutoDismissInitScriptIndicator = useAppStore( (state) => state.setAutoDismissInitScriptIndicator ); + const setCurrentProject = useAppStore((state) => state.setCurrentProject); const loadingRef = useRef(null); const currentProjectRef = useRef(null); @@ -107,6 +108,28 @@ export function useProjectSettingsLoader() { result.settings.autoDismissInitScriptIndicator ); } + + // Apply activeClaudeApiProfileId if present + // This is stored directly on the project, so we need to update the currentProject + // Type assertion needed because API returns Record + const settingsWithProfile = result.settings as Record; + const activeClaudeApiProfileId = settingsWithProfile.activeClaudeApiProfileId as + | string + | null + | undefined; + if (activeClaudeApiProfileId !== undefined) { + const updatedProject = useAppStore.getState().currentProject; + if ( + updatedProject && + updatedProject.path === requestedProjectPath && + updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId + ) { + setCurrentProject({ + ...updatedProject, + activeClaudeApiProfileId, + }); + } + } } } catch (error) { console.error('Failed to load project settings:', error); diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 58b3ec2d..e4001c05 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -206,6 +206,10 @@ export function parseLocalStorageSettings(): Partial | null { worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean), lastProjectDir: lastProjectDir || (state.lastProjectDir as string), recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]), + // Claude API Profiles + claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [], + activeClaudeApiProfileId: + (state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null, }; } catch (error) { logger.error('Failed to parse localStorage settings:', error); @@ -326,6 +330,20 @@ export function mergeSettings( merged.currentProjectId = localSettings.currentProjectId; } + // Claude API Profiles - preserve from localStorage if server is empty + if ( + (!serverSettings.claudeApiProfiles || serverSettings.claudeApiProfiles.length === 0) && + localSettings.claudeApiProfiles && + localSettings.claudeApiProfiles.length > 0 + ) { + merged.claudeApiProfiles = localSettings.claudeApiProfiles; + } + + // Active Claude API Profile ID - preserve from localStorage if server doesn't have one + if (!serverSettings.activeClaudeApiProfileId && localSettings.activeClaudeApiProfileId) { + merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId; + } + return merged; } @@ -671,6 +689,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { }, mcpServers: settings.mcpServers ?? [], promptCustomization: settings.promptCustomization ?? {}, + claudeApiProfiles: settings.claudeApiProfiles ?? [], + activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null, projects, currentProject, trashedProjects: settings.trashedProjects ?? [], @@ -732,6 +752,8 @@ function buildSettingsUpdateFromStore(): Record { keyboardShortcuts: state.keyboardShortcuts, mcpServers: state.mcpServers, promptCustomization: state.promptCustomization, + claudeApiProfiles: state.claudeApiProfiles, + activeClaudeApiProfileId: state.activeClaudeApiProfileId, projects: state.projects, trashedProjects: state.trashedProjects, currentProjectId: state.currentProject?.id ?? null, diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index c978b6a7..c2b48ae7 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -72,6 +72,8 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'defaultTerminalId', 'promptCustomization', 'eventHooks', + 'claudeApiProfiles', + 'activeClaudeApiProfileId', 'projects', 'trashedProjects', 'currentProjectId', // ID of currently open project @@ -628,6 +630,8 @@ export async function refreshSettingsFromServer(): Promise { defaultEditorCommand: serverSettings.defaultEditorCommand ?? null, defaultTerminalId: serverSettings.defaultTerminalId ?? null, promptCustomization: serverSettings.promptCustomization ?? {}, + claudeApiProfiles: serverSettings.claudeApiProfiles ?? [], + activeClaudeApiProfileId: serverSettings.activeClaudeApiProfileId ?? null, projects: serverSettings.projects, trashedProjects: serverSettings.trashedProjects, projectHistory: serverSettings.projectHistory, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 9c834955..f3ae5914 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -479,7 +479,8 @@ export interface FeaturesAPI { featureId: string ) => Promise<{ success: boolean; content?: string | null; error?: string }>; generateTitle: ( - description: string + description: string, + projectPath?: string ) => Promise<{ success: boolean; title?: string; error?: string }>; } @@ -706,7 +707,8 @@ export interface ElectronAPI { originalText: string, enhancementMode: string, model?: string, - thinkingLevel?: string + thinkingLevel?: string, + projectPath?: string ) => Promise<{ success: boolean; enhancedText?: string; @@ -3173,7 +3175,7 @@ function createMockFeaturesAPI(): FeaturesAPI { return { success: true, content: content || null }; }, - generateTitle: async (description: string) => { + generateTitle: async (description: string, _projectPath?: string) => { console.log('[Mock] Generating title for:', description.substring(0, 50)); // Mock title generation - just take first few words const words = description.split(/\s+/).slice(0, 6).join(' '); @@ -3349,6 +3351,13 @@ export interface Project { isFavorite?: boolean; // Pin project to top of dashboard icon?: string; // Lucide icon name for project identification customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/ + /** + * Override the active Claude API profile for this project. + * - undefined: Use global setting (activeClaudeApiProfileId) + * - null: Explicitly use Direct Anthropic API (no profile) + * - string: Use specific profile by ID + */ + activeClaudeApiProfileId?: string | null; } export interface TrashedProject extends Project { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index ba2b8dd3..d70273d9 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1657,8 +1657,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/features/delete', { projectPath, featureId }), getAgentOutput: (projectPath: string, featureId: string) => this.post('/api/features/agent-output', { projectPath, featureId }), - generateTitle: (description: string) => - this.post('/api/features/generate-title', { description }), + generateTitle: (description: string, projectPath?: string) => + this.post('/api/features/generate-title', { description, projectPath }), bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial) => this.post('/api/features/bulk-update', { projectPath, featureIds, updates }), bulkDelete: (projectPath: string, featureIds: string[]) => @@ -1743,13 +1743,15 @@ export class HttpApiClient implements ElectronAPI { originalText: string, enhancementMode: string, model?: string, - thinkingLevel?: string + thinkingLevel?: string, + projectPath?: string ): Promise => this.post('/api/enhance-prompt', { originalText, enhancementMode, model, thinkingLevel, + projectPath, }), }; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 6030033d..a172ffe4 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; // Note: persist middleware removed - settings now sync via API (use-settings-sync.ts) import type { Project, TrashedProject } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron'; +import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; import { setItem, getItem } from '@/lib/storage'; import { @@ -31,6 +32,7 @@ import type { ModelDefinition, ServerLogLevel, EventHook, + ClaudeApiProfile, } from '@automaker/types'; import { getAllCursorModelIds, @@ -747,6 +749,10 @@ export interface AppState { // Event Hooks eventHooks: EventHook[]; // Event hooks for custom commands or webhooks + // Claude API Profiles + claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles + activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API) + // Project Analysis projectAnalysis: ProjectAnalysis | null; isAnalyzing: boolean; @@ -1030,6 +1036,9 @@ export interface AppActions { getEffectiveFontSans: () => string | null; // Get effective UI font (project override -> global -> null for default) getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default) + // Claude API Profile actions (per-project override) + setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => void; // Set per-project Claude API profile (undefined = use global, null = direct API, string = specific profile) + // Feature actions setFeatures: (features: Feature[]) => void; updateFeature: (id: string, updates: Partial) => void; @@ -1180,6 +1189,13 @@ export interface AppActions { // Event Hook actions setEventHooks: (hooks: EventHook[]) => void; + // Claude API Profile actions + addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise; + updateClaudeApiProfile: (id: string, updates: Partial) => Promise; + deleteClaudeApiProfile: (id: string) => Promise; + setActiveClaudeApiProfile: (id: string | null) => Promise; + setClaudeApiProfiles: (profiles: ClaudeApiProfile[]) => Promise; + // MCP Server actions addMCPServer: (server: Omit) => void; updateMCPServer: (id: string, updates: Partial) => void; @@ -1438,6 +1454,8 @@ const initialState: AppState = { subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default promptCustomization: {}, // Empty by default - all prompts use built-in defaults eventHooks: [], // No event hooks configured by default + claudeApiProfiles: [], // No Claude API profiles configured by default + activeClaudeApiProfileId: null, // Use direct Anthropic API by default projectAnalysis: null, isAnalyzing: false, boardBackgroundByProject: {}, @@ -1936,6 +1954,47 @@ export const useAppStore = create()((set, get) => ({ return getEffectiveFont(currentProject?.fontFamilyMono, fontFamilyMono, UI_MONO_FONT_OPTIONS); }, + // Claude API Profile actions (per-project override) + setProjectClaudeApiProfile: (projectId, profileId) => { + // Find the project to get its path for server sync + const project = get().projects.find((p) => p.id === projectId); + if (!project) { + console.error('Cannot set Claude API profile: project not found'); + return; + } + + // Update the project's activeClaudeApiProfileId property + // undefined means "use global", null means "explicit direct API", string means specific profile + const projects = get().projects.map((p) => + p.id === projectId ? { ...p, activeClaudeApiProfileId: profileId } : p + ); + set({ projects }); + + // Also update currentProject if it's the same project + const currentProject = get().currentProject; + if (currentProject?.id === projectId) { + set({ + currentProject: { + ...currentProject, + activeClaudeApiProfileId: profileId, + }, + }); + } + + // Persist to server + // Note: undefined means "use global" but JSON doesn't serialize undefined, + // so we use a special marker string "__USE_GLOBAL__" to signal deletion + const httpClient = getHttpApiClient(); + const serverValue = profileId === undefined ? '__USE_GLOBAL__' : profileId; + httpClient.settings + .updateProject(project.path, { + activeClaudeApiProfileId: serverValue, + }) + .catch((error) => { + console.error('Failed to persist activeClaudeApiProfileId:', error); + }); + }, + // Feature actions setFeatures: (features) => set({ features }), @@ -2459,6 +2518,82 @@ export const useAppStore = create()((set, get) => ({ // Event Hook actions setEventHooks: (hooks) => set({ eventHooks: hooks }), + // Claude API Profile actions + addClaudeApiProfile: async (profile) => { + set({ claudeApiProfiles: [...get().claudeApiProfiles, profile] }); + // Sync immediately to persist profile + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + updateClaudeApiProfile: async (id, updates) => { + set({ + claudeApiProfiles: get().claudeApiProfiles.map((p) => + p.id === id ? { ...p, ...updates } : p + ), + }); + // Sync immediately to persist changes + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + deleteClaudeApiProfile: async (id) => { + const currentActiveId = get().activeClaudeApiProfileId; + const projects = get().projects; + + // Find projects that have per-project override referencing the deleted profile + const affectedProjects = projects.filter((p) => p.activeClaudeApiProfileId === id); + + // Update state: remove profile and clear references + set({ + claudeApiProfiles: get().claudeApiProfiles.filter((p) => p.id !== id), + // Clear global active if the deleted profile was active + activeClaudeApiProfileId: currentActiveId === id ? null : currentActiveId, + // Clear per-project overrides that reference the deleted profile + projects: projects.map((p) => + p.activeClaudeApiProfileId === id ? { ...p, activeClaudeApiProfileId: undefined } : p + ), + }); + + // Also update currentProject if it was using the deleted profile + const currentProject = get().currentProject; + if (currentProject?.activeClaudeApiProfileId === id) { + set({ + currentProject: { ...currentProject, activeClaudeApiProfileId: undefined }, + }); + } + + // Persist per-project changes to server (use __USE_GLOBAL__ marker) + const httpClient = getHttpApiClient(); + await Promise.all( + affectedProjects.map((project) => + httpClient.settings + .updateProject(project.path, { activeClaudeApiProfileId: '__USE_GLOBAL__' }) + .catch((error) => { + console.error(`Failed to clear profile override for project ${project.name}:`, error); + }) + ) + ); + + // Sync global settings to persist deletion + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + setActiveClaudeApiProfile: async (id) => { + set({ activeClaudeApiProfileId: id }); + // Sync immediately to persist active profile change + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + setClaudeApiProfiles: async (profiles) => { + set({ claudeApiProfiles: profiles }); + // Sync immediately to persist profiles + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + // MCP Server actions addMCPServer: (server) => { const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; diff --git a/docs/UNIFIED_API_KEY_PROFILES.md b/docs/UNIFIED_API_KEY_PROFILES.md new file mode 100644 index 00000000..4bb8e936 --- /dev/null +++ b/docs/UNIFIED_API_KEY_PROFILES.md @@ -0,0 +1,448 @@ +# Unified Claude API Key and Profile System + +This document describes the implementation of a unified API key sourcing system for Claude API profiles, allowing flexible configuration of how API keys are resolved. + +## Problem Statement + +Previously, Automaker had two separate systems for configuring Claude API access: + +1. **API Keys section** (`credentials.json`): Stored Anthropic API key, used when no profile was active +2. **API Profiles section** (`settings.json`): Stored alternative endpoint configs (e.g., z.AI GLM) with their own inline API keys + +This created several issues: + +- Users configured Anthropic key in one place, but alternative endpoints in another +- No way to create a "Direct Anthropic" profile that reused the stored credentials +- Environment variable detection didn't integrate with the profile system +- Duplicated API key entry when users wanted the same key for multiple configurations + +## Solution Overview + +The solution introduces a flexible `apiKeySource` field on Claude API profiles that determines where the API key is resolved from: + +| Source | Description | +| ------------- | ----------------------------------------------------------------- | +| `inline` | API key stored directly in the profile (legacy behavior, default) | +| `env` | Uses `ANTHROPIC_API_KEY` environment variable | +| `credentials` | Uses the Anthropic key from Settings → API Keys | + +This allows: + +- A single API key to be shared across multiple profile configurations +- "Direct Anthropic" profile that references saved credentials +- Environment variable support for CI/CD and containerized deployments +- Backwards compatibility with existing inline key profiles + +## Implementation Details + +### Type Changes + +#### New Type: `ApiKeySource` + +```typescript +// libs/types/src/settings.ts +export type ApiKeySource = 'inline' | 'env' | 'credentials'; +``` + +#### Updated Interface: `ClaudeApiProfile` + +```typescript +export interface ClaudeApiProfile { + id: string; + name: string; + baseUrl: string; + + // NEW: API key sourcing strategy (default: 'inline' for backwards compat) + apiKeySource?: ApiKeySource; + + // Now optional - only required when apiKeySource = 'inline' + apiKey?: string; + + // Existing fields unchanged... + useAuthToken?: boolean; + timeoutMs?: number; + modelMappings?: { haiku?: string; sonnet?: string; opus?: string }; + disableNonessentialTraffic?: boolean; +} +``` + +#### Updated Interface: `ClaudeApiProfileTemplate` + +```typescript +export interface ClaudeApiProfileTemplate { + name: string; + baseUrl: string; + defaultApiKeySource?: ApiKeySource; // NEW: Suggested source for this template + useAuthToken: boolean; + // ... other fields +} +``` + +### Provider Templates + +The following provider templates are available: + +#### Direct Anthropic + +```typescript +{ + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + defaultApiKeySource: 'credentials', + useAuthToken: false, + description: 'Standard Anthropic API with your API key', + apiKeyUrl: 'https://console.anthropic.com/settings/keys', +} +``` + +#### OpenRouter + +Access Claude and 300+ other models through OpenRouter's unified API. + +```typescript +{ + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api', + defaultApiKeySource: 'inline', + useAuthToken: true, + description: 'Access Claude and 300+ models via OpenRouter', + apiKeyUrl: 'https://openrouter.ai/keys', +} +``` + +**Notes:** + +- Uses `ANTHROPIC_AUTH_TOKEN` with your OpenRouter API key +- No model mappings by default - OpenRouter auto-maps Anthropic models +- Can customize model mappings to use any OpenRouter-supported model (e.g., `openai/gpt-5.1-codex-max`) + +#### z.AI GLM + +```typescript +{ + name: 'z.AI GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'GLM-4.5-Air', + sonnet: 'GLM-4.7', + opus: 'GLM-4.7', + }, + disableNonessentialTraffic: true, + description: '3× usage at fraction of cost via GLM Coding Plan', + apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', +} +``` + +#### MiniMax + +MiniMax M2.1 coding model with extended context support. + +```typescript +{ + name: 'MiniMax', + baseUrl: 'https://api.minimax.io/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 coding model with extended context', + apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', +} +``` + +#### MiniMax (China) + +Same as MiniMax but using the China-region endpoint. + +```typescript +{ + name: 'MiniMax (China)', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 for users in China', + apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', +} +``` + +### Server-Side Changes + +#### 1. Environment Building (`claude-provider.ts`) + +The `buildEnv()` function now resolves API keys based on the `apiKeySource`: + +```typescript +function buildEnv( + profile?: ClaudeApiProfile, + credentials?: Credentials // NEW parameter +): Record { + if (profile) { + // Resolve API key based on source strategy + let apiKey: string | undefined; + const source = profile.apiKeySource ?? 'inline'; + + switch (source) { + case 'inline': + apiKey = profile.apiKey; + break; + case 'env': + apiKey = process.env.ANTHROPIC_API_KEY; + break; + case 'credentials': + apiKey = credentials?.apiKeys?.anthropic; + break; + } + + // ... rest of profile-based env building + } + // ... no-profile fallback +} +``` + +#### 2. Settings Helper (`settings-helpers.ts`) + +The `getActiveClaudeApiProfile()` function now returns both profile and credentials: + +```typescript +export interface ActiveClaudeApiProfileResult { + profile: ClaudeApiProfile | undefined; + credentials: Credentials | undefined; +} + +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise { + // Returns both profile and credentials for API key resolution +} +``` + +#### 3. Auto-Migration (`settings-service.ts`) + +A v4→v5 migration automatically creates a "Direct Anthropic" profile for existing users: + +```typescript +// Migration v4 -> v5: Auto-create "Direct Anthropic" profile +if (storedVersion < 5) { + const credentials = await this.getCredentials(); + const hasAnthropicKey = !!credentials.apiKeys?.anthropic; + const hasNoProfiles = !result.claudeApiProfiles?.length; + const hasNoActiveProfile = !result.activeClaudeApiProfileId; + + if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { + // Create "Direct Anthropic" profile with apiKeySource: 'credentials' + // and set it as active + } +} +``` + +#### 4. Updated Call Sites + +All files that call `getActiveClaudeApiProfile()` were updated to: + +1. Destructure both `profile` and `credentials` from the result +2. Pass `credentials` to the provider via `ExecuteOptions` + +**Files updated:** + +- `apps/server/src/services/agent-service.ts` +- `apps/server/src/services/auto-mode-service.ts` (2 locations) +- `apps/server/src/services/ideation-service.ts` (2 locations) +- `apps/server/src/providers/simple-query-service.ts` +- `apps/server/src/routes/enhance-prompt/routes/enhance.ts` +- `apps/server/src/routes/context/routes/describe-file.ts` +- `apps/server/src/routes/context/routes/describe-image.ts` +- `apps/server/src/routes/github/routes/validate-issue.ts` +- `apps/server/src/routes/worktree/routes/generate-commit-message.ts` +- `apps/server/src/routes/features/routes/generate-title.ts` +- `apps/server/src/routes/backlog-plan/generate-plan.ts` +- `apps/server/src/routes/app-spec/sync-spec.ts` +- `apps/server/src/routes/app-spec/generate-features-from-spec.ts` +- `apps/server/src/routes/app-spec/generate-spec.ts` +- `apps/server/src/routes/suggestions/generate-suggestions.ts` + +### UI Changes + +#### 1. Profile Form (`api-profiles-section.tsx`) + +Added an API Key Source selector dropdown: + +```tsx + +``` + +The API Key input field is now conditionally rendered only when `apiKeySource === 'inline'`. + +#### 2. API Keys Section (`api-keys-section.tsx`) + +Added an informational note: + +> API Keys saved here can be used by API Profiles with "credentials" as the API key source. This lets you share a single key across multiple profile configurations without re-entering it. + +## User Flows + +### New User Flow + +1. Go to Settings → API Keys +2. Enter Anthropic API key and save +3. Go to Settings → Providers → Claude +4. Create new profile from "Direct Anthropic" template +5. API Key Source defaults to "credentials" - no need to re-enter key +6. Save profile and set as active + +### Existing User Migration + +When an existing user with an Anthropic API key (but no profiles) loads settings: + +1. System detects v4→v5 migration needed +2. Automatically creates "Direct Anthropic" profile with `apiKeySource: 'credentials'` +3. Sets new profile as active +4. User's existing workflow continues to work seamlessly + +### Environment Variable Flow + +For CI/CD or containerized deployments: + +1. Set `ANTHROPIC_API_KEY` in environment +2. Create profile with `apiKeySource: 'env'` +3. Profile will use the environment variable at runtime + +## Backwards Compatibility + +- Profiles without `apiKeySource` field default to `'inline'` +- Existing profiles with inline `apiKey` continue to work unchanged +- No changes to the credentials file format +- Settings version bumped from 4 to 5 (migration is additive) + +## Files Changed + +| File | Changes | +| --------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `libs/types/src/settings.ts` | Added `ApiKeySource` type, updated `ClaudeApiProfile`, added Direct Anthropic template | +| `libs/types/src/provider.ts` | Added `credentials` field to `ExecuteOptions` | +| `libs/types/src/index.ts` | Exported `ApiKeySource` type | +| `apps/server/src/providers/claude-provider.ts` | Updated `buildEnv()` to resolve keys from different sources | +| `apps/server/src/lib/settings-helpers.ts` | Updated return type to include credentials | +| `apps/server/src/services/settings-service.ts` | Added v4→v5 auto-migration | +| `apps/server/src/providers/simple-query-service.ts` | Added credentials passthrough | +| `apps/server/src/services/*.ts` | Updated to pass credentials | +| `apps/server/src/routes/**/*.ts` | Updated to pass credentials (15 files) | +| `apps/ui/src/.../api-profiles-section.tsx` | Added API Key Source selector | +| `apps/ui/src/.../api-keys-section.tsx` | Added profile usage note | + +## Testing + +To verify the implementation: + +1. **New user flow**: Create "Direct Anthropic" profile, select `credentials` source, enter key in API Keys section → verify it works +2. **Existing user migration**: User with credentials.json key sees auto-created "Direct Anthropic" profile +3. **Env var support**: Create profile with `env` source, set ANTHROPIC_API_KEY → verify it works +4. **z.AI GLM unchanged**: Existing profiles with inline keys continue working +5. **Backwards compat**: Profiles without `apiKeySource` field default to `inline` + +```bash +# Build and run +npm run build:packages +npm run dev:web + +# Run server tests +npm run test:server +``` + +## Per-Project Profile Override + +Projects can override the global Claude API profile selection, allowing different projects to use different endpoints or configurations. + +### Configuration + +In **Project Settings → Claude**, users can select: + +| Option | Behavior | +| ------------------------ | ------------------------------------------------------------------ | +| **Use Global Setting** | Inherits the active profile from global settings (default) | +| **Direct Anthropic API** | Explicitly uses direct Anthropic API, bypassing any global profile | +| **\** | Uses that specific profile for this project only | + +### Storage + +The per-project setting is stored in `.automaker/settings.json`: + +```json +{ + "activeClaudeApiProfileId": "profile-id-here" +} +``` + +- `undefined` (or key absent): Use global setting +- `null`: Explicitly use Direct Anthropic API +- `""`: Use specific profile by ID + +### Implementation + +The `getActiveClaudeApiProfile()` function accepts an optional `projectPath` parameter: + +```typescript +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]', + projectPath?: string // Optional: check project settings first +): Promise; +``` + +When `projectPath` is provided: + +1. Project settings are checked first for `activeClaudeApiProfileId` +2. If project has a value (including `null`), that takes precedence +3. If project has no override (`undefined`), falls back to global setting + +### Scope + +**Important:** Per-project profiles only affect Claude model calls. When other providers are used (Codex, OpenCode, Cursor), the Claude API profile setting has no effect—those providers use their own configuration. + +Affected operations when using Claude models: + +- Agent chat and feature implementation +- Code analysis and suggestions +- Commit message generation +- Spec generation and sync +- Issue validation +- Backlog planning + +### Use Cases + +1. **Experimentation**: Test z.AI GLM or MiniMax on a side project while keeping production projects on Direct Anthropic +2. **Cost optimization**: Use cheaper endpoints for hobby projects, premium for work projects +3. **Regional compliance**: Use China endpoints for projects with data residency requirements + +## Future Enhancements + +Potential future improvements: + +1. **UI indicators**: Show whether credentials/env key is configured when selecting those sources +2. **Validation**: Warn if selected source has no key configured +3. **Per-provider credentials**: Support different credential keys for different providers +4. **Key rotation**: Support for rotating keys without updating profiles diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 21985230..123dbeda 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -161,6 +161,10 @@ export type { EventHookHttpAction, EventHookAction, EventHook, + // Claude API profile types + ApiKeySource, + ClaudeApiProfile, + ClaudeApiProfileTemplate, } from './settings.js'; export { DEFAULT_KEYBOARD_SHORTCUTS, @@ -175,6 +179,8 @@ export { getThinkingTokenBudget, // Event hook constants EVENT_HOOK_TRIGGER_LABELS, + // Claude API profile constants + CLAUDE_API_PROFILE_TEMPLATES, } from './settings.js'; // Model display constants diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index e934e999..6fddb460 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -2,7 +2,7 @@ * Shared types for AI model providers */ -import type { ThinkingLevel } from './settings.js'; +import type { ThinkingLevel, ClaudeApiProfile, Credentials } from './settings.js'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; /** @@ -209,6 +209,17 @@ export interface ExecuteOptions { type: 'json_schema'; schema: Record; }; + /** + * Active Claude API profile for alternative endpoint configuration. + * When set, uses profile's settings (base URL, auth, model mappings) instead of direct Anthropic API. + * When undefined, uses direct Anthropic API (via API key or Claude Max CLI OAuth). + */ + claudeApiProfile?: ClaudeApiProfile; + /** + * Credentials for resolving 'credentials' apiKeySource in Claude API profiles. + * When a profile has apiKeySource='credentials', the Anthropic key from this object is used. + */ + credentials?: Credentials; } /** diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 64c3df41..21ce10a8 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -101,6 +101,137 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number /** ModelProvider - AI model provider for credentials and API key management */ export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; +// ============================================================================ +// Claude API Profiles - Configuration for Claude-compatible API endpoints +// ============================================================================ + +/** + * ApiKeySource - Strategy for sourcing API keys + * + * - 'inline': API key stored directly in the profile (legacy/default behavior) + * - 'env': Use ANTHROPIC_API_KEY environment variable + * - 'credentials': Use the Anthropic key from Settings → API Keys (credentials.json) + */ +export type ApiKeySource = 'inline' | 'env' | 'credentials'; + +/** + * ClaudeApiProfile - Configuration for a Claude-compatible API endpoint + * + * Allows using alternative providers like z.AI GLM, AWS Bedrock, etc. + */ +export interface ClaudeApiProfile { + /** Unique identifier (uuid) */ + id: string; + /** Display name (e.g., "z.AI GLM", "AWS Bedrock") */ + name: string; + /** ANTHROPIC_BASE_URL - custom API endpoint */ + baseUrl: string; + /** + * API key sourcing strategy (default: 'inline' for backwards compatibility) + * - 'inline': Use apiKey field value + * - 'env': Use ANTHROPIC_API_KEY environment variable + * - 'credentials': Use the Anthropic key from credentials.json + */ + apiKeySource?: ApiKeySource; + /** API key value (only required when apiKeySource = 'inline' or undefined) */ + apiKey?: string; + /** If true, use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY */ + useAuthToken?: boolean; + /** API_TIMEOUT_MS override in milliseconds */ + timeoutMs?: number; + /** Optional model name mappings */ + modelMappings?: { + /** Maps to ANTHROPIC_DEFAULT_HAIKU_MODEL */ + haiku?: string; + /** Maps to ANTHROPIC_DEFAULT_SONNET_MODEL */ + sonnet?: string; + /** Maps to ANTHROPIC_DEFAULT_OPUS_MODEL */ + opus?: string; + }; + /** Set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 */ + disableNonessentialTraffic?: boolean; +} + +/** Known provider templates for quick setup */ +export interface ClaudeApiProfileTemplate { + name: string; + baseUrl: string; + /** Default API key source for this template (user chooses when creating) */ + defaultApiKeySource?: ApiKeySource; + useAuthToken: boolean; + timeoutMs?: number; + modelMappings?: ClaudeApiProfile['modelMappings']; + disableNonessentialTraffic?: boolean; + description: string; + apiKeyUrl?: string; +} + +/** Predefined templates for known Claude-compatible providers */ +export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [ + { + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + defaultApiKeySource: 'credentials', + useAuthToken: false, + description: 'Standard Anthropic API with your API key', + apiKeyUrl: 'https://console.anthropic.com/settings/keys', + }, + { + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api', + defaultApiKeySource: 'inline', + useAuthToken: true, + description: 'Access Claude and 300+ models via OpenRouter', + apiKeyUrl: 'https://openrouter.ai/keys', + }, + { + name: 'z.AI GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'GLM-4.5-Air', + sonnet: 'GLM-4.7', + opus: 'GLM-4.7', + }, + disableNonessentialTraffic: true, + description: '3× usage at fraction of cost via GLM Coding Plan', + apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', + }, + { + name: 'MiniMax', + baseUrl: 'https://api.minimax.io/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 coding model with extended context', + apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', + }, + { + name: 'MiniMax (China)', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 for users in China', + apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', + }, + // Future: Add AWS Bedrock, Google Vertex, etc. +]; + // ============================================================================ // Event Hooks - Custom actions triggered by system events // ============================================================================ @@ -658,6 +789,19 @@ export interface GlobalSettings { * @see EventHook for configuration details */ eventHooks?: EventHook[]; + + // Claude API Profiles Configuration + /** + * Claude-compatible API endpoint profiles + * Allows using alternative providers like z.AI GLM, AWS Bedrock, etc. + */ + claudeApiProfiles?: ClaudeApiProfile[]; + + /** + * Active profile ID (null/undefined = use direct Anthropic API) + * When set, the corresponding profile's settings will be used for Claude API calls + */ + activeClaudeApiProfileId?: string | null; } /** @@ -794,6 +938,15 @@ export interface ProjectSettings { automodeEnabled?: boolean; /** Maximum concurrent agents for this project (overrides global maxConcurrency) */ maxConcurrentAgents?: number; + + // Claude API Profile Override (per-project) + /** + * Override the active Claude API profile for this project. + * - undefined: Use global setting (activeClaudeApiProfileId) + * - null: Explicitly use Direct Anthropic API (no profile) + * - string: Use specific profile by ID + */ + activeClaudeApiProfileId?: string | null; } /** @@ -827,7 +980,7 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = { }; /** Current version of the global settings schema */ -export const SETTINGS_VERSION = 4; +export const SETTINGS_VERSION = 5; /** Current version of the credentials schema */ export const CREDENTIALS_VERSION = 1; /** Current version of the project settings schema */ @@ -913,6 +1066,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { skillsSources: ['user', 'project'], enableSubagents: true, subagentsSources: ['user', 'project'], + claudeApiProfiles: [], + activeClaudeApiProfileId: null, }; /** Default credentials (empty strings - user must provide API keys) */ diff --git a/package-lock.json b/package-lock.json index 14355b8b..c851c9aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6190,6 +6190,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6199,7 +6200,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -8410,6 +8411,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -11303,7 +11305,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11325,7 +11326,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11347,7 +11347,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11369,7 +11368,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11391,7 +11389,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11413,7 +11410,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11435,7 +11431,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11457,7 +11452,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11479,7 +11473,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11501,7 +11494,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11523,7 +11515,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, diff --git a/package.json b/package.json index 96c9bf1e..f7388410 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "scripts": { "postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs", "fix:lockfile": "node scripts/fix-lockfile-urls.mjs", - "dev": "./start-automaker.sh", - "start": "./start-automaker.sh --production", + "dev": "node start-automaker.mjs", + "start": "node start-automaker.mjs --production", "_dev:web": "npm run dev:web --workspace=apps/ui", "_dev:electron": "npm run dev:electron --workspace=apps/ui", "_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui", diff --git a/start-automaker.mjs b/start-automaker.mjs new file mode 100644 index 00000000..97362312 --- /dev/null +++ b/start-automaker.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +/** + * Cross-platform launcher for Automaker + * Works on Windows (CMD, PowerShell, Git Bash) and Unix (macOS, Linux) + */ + +import { spawn, spawnSync } from 'child_process'; +import { existsSync } from 'fs'; +import { platform } from 'os'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const isWindows = platform() === 'win32'; +const args = process.argv.slice(2); + +/** + * Detect the bash variant by checking $OSTYPE + * This is more reliable than path-based detection since bash.exe in PATH + * could be Git Bash, WSL, or something else + * @param {string} bashPath - Path to bash executable + * @returns {'WSL' | 'MSYS' | 'CYGWIN' | 'UNKNOWN'} The detected bash variant + */ +function detectBashVariant(bashPath) { + try { + const result = spawnSync(bashPath, ['-c', 'echo $OSTYPE'], { + stdio: 'pipe', + timeout: 2000, + }); + if (result.status === 0) { + const ostype = result.stdout.toString().trim(); + // WSL reports 'linux-gnu' or similar Linux identifier + if (ostype === 'linux-gnu' || ostype.startsWith('linux')) return 'WSL'; + // MSYS2/Git Bash reports 'msys' or 'mingw*' + if (ostype.startsWith('msys') || ostype.startsWith('mingw')) return 'MSYS'; + // Cygwin reports 'cygwin' + if (ostype.startsWith('cygwin')) return 'CYGWIN'; + } + } catch { + // Fall through to path-based detection + } + // Fallback to path-based detection if $OSTYPE check fails + const lower = bashPath.toLowerCase(); + if (lower.includes('cygwin')) return 'CYGWIN'; + if (lower.includes('system32')) return 'WSL'; + // Default to MSYS (Git Bash) as it's the most common + return 'MSYS'; +} + +/** + * Convert Windows path to Unix-style for the detected bash variant + * @param {string} windowsPath - Windows-style path (e.g., C:\path\to\file) + * @param {string} bashCmd - Path to bash executable (used to detect variant) + * @returns {string} Unix-style path appropriate for the bash variant + */ +function convertPathForBash(windowsPath, bashCmd) { + // Input validation + if (!windowsPath || typeof windowsPath !== 'string') { + throw new Error('convertPathForBash: invalid windowsPath'); + } + if (!bashCmd || typeof bashCmd !== 'string') { + throw new Error('convertPathForBash: invalid bashCmd'); + } + + let unixPath = windowsPath.replace(/\\/g, '/'); + if (/^[A-Za-z]:/.test(unixPath)) { + const drive = unixPath[0].toLowerCase(); + const pathPart = unixPath.slice(2); + + // Detect bash variant via $OSTYPE (more reliable than path-based) + const variant = detectBashVariant(bashCmd); + switch (variant) { + case 'CYGWIN': + // Cygwin expects /cygdrive/c/path format + return `/cygdrive/${drive}${pathPart}`; + case 'WSL': + // WSL expects /mnt/c/path format + return `/mnt/${drive}${pathPart}`; + case 'MSYS': + default: + // MSYS2/Git Bash expects /c/path format + return `/${drive}${pathPart}`; + } + } + return unixPath; +} + +/** + * Find bash executable on Windows + */ +function findBashOnWindows() { + const possiblePaths = [ + // Git Bash (most common) + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + // MSYS2 + 'C:\\msys64\\usr\\bin\\bash.exe', + 'C:\\msys32\\usr\\bin\\bash.exe', + // Cygwin + 'C:\\cygwin64\\bin\\bash.exe', + 'C:\\cygwin\\bin\\bash.exe', + // WSL bash (available in PATH on Windows 10+) + 'bash.exe', + ]; + + for (const bashPath of possiblePaths) { + if (bashPath === 'bash.exe') { + // Check if bash is in PATH + try { + const result = spawnSync('where', ['bash.exe'], { stdio: 'pipe' }); + if (result?.status === 0) { + return 'bash.exe'; + } + } catch (err) { + // where command failed, continue checking other paths + } + } else if (existsSync(bashPath)) { + return bashPath; + } + } + + return null; +} + +/** + * Run the bash script + */ +function runBashScript() { + const scriptPath = join(__dirname, 'start-automaker.sh'); + + if (!existsSync(scriptPath)) { + console.error('Error: start-automaker.sh not found'); + process.exit(1); + } + + let bashCmd; + let bashArgs; + + if (isWindows) { + bashCmd = findBashOnWindows(); + + if (!bashCmd) { + console.error('Error: Could not find bash on Windows.'); + console.error('Please install Git for Windows from https://git-scm.com/download/win'); + console.error(''); + console.error('Alternatively, you can run these commands directly:'); + console.error(' npm run dev:web - Web browser mode'); + console.error(' npm run dev:electron - Desktop app mode'); + process.exit(1); + } + + // Convert Windows path to appropriate Unix-style for the detected bash variant + const unixPath = convertPathForBash(scriptPath, bashCmd); + bashArgs = [unixPath, ...args]; + } else { + bashCmd = '/bin/bash'; + bashArgs = [scriptPath, ...args]; + } + + const child = spawn(bashCmd, bashArgs, { + stdio: 'inherit', + env: { + ...process.env, + // Ensure proper terminal handling + TERM: process.env.TERM || 'xterm-256color', + }, + // shell: false ensures signals are forwarded directly to the child process + shell: false, + }); + + child.on('error', (err) => { + if (err.code === 'ENOENT') { + console.error(`Error: Could not find bash at "${bashCmd}"`); + console.error('Please ensure Git Bash or another bash shell is installed.'); + } else { + console.error('Error launching Automaker:', err.message); + } + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + // Process was killed by a signal - exit with 1 to indicate abnormal termination + // (Unix convention is 128 + signal number, but we use 1 for simplicity) + process.exit(1); + } + process.exit(code ?? 0); + }); + + // Forward signals to child process (guard against race conditions) + process.on('SIGINT', () => { + if (!child.killed) child.kill('SIGINT'); + }); + process.on('SIGTERM', () => { + if (!child.killed) child.kill('SIGTERM'); + }); +} + +runBashScript(); diff --git a/start-automaker.sh b/start-automaker.sh index ef7b1172..ecb499b9 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -37,6 +37,37 @@ DEFAULT_SERVER_PORT=3008 WEB_PORT=$DEFAULT_WEB_PORT SERVER_PORT=$DEFAULT_SERVER_PORT +# Port validation function +# Returns 0 if valid, 1 if invalid (with error message printed) +validate_port() { + local port="$1" + local port_name="${2:-port}" + + # Check if port is a number + if ! [[ "$port" =~ ^[0-9]+$ ]]; then + echo "${C_RED}Error:${RESET} $port_name must be a number, got '$port'" + return 1 + fi + + # Check if port is in valid range (1-65535) + if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + echo "${C_RED}Error:${RESET} $port_name must be between 1-65535, got '$port'" + return 1 + fi + + # Check if port is in privileged range (warning only) + if [ "$port" -lt 1024 ]; then + echo "${C_YELLOW}Warning:${RESET} $port_name $port is in privileged range (requires root/admin)" + fi + + return 0 +} + +# Hostname configuration +# Use VITE_HOSTNAME if explicitly set, otherwise default to localhost +# Note: Don't use $HOSTNAME as it's a bash built-in containing the machine's hostname +APP_HOST="${VITE_HOSTNAME:-localhost}" + # Extract VERSION from apps/ui/package.json (the actual app version, not monorepo version) if command -v node &> /dev/null; then VERSION="v$(node -p "require('$SCRIPT_DIR/apps/ui/package.json').version" 2>/dev/null || echo "0.11.0")" @@ -504,10 +535,23 @@ check_ports() { break ;; [uU]|[uU][sS][eE]) + # Collect both ports first read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web - WEB_PORT=${input_web:-$DEFAULT_WEB_PORT} + input_web=${input_web:-$DEFAULT_WEB_PORT} read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server - SERVER_PORT=${input_server:-$DEFAULT_SERVER_PORT} + input_server=${input_server:-$DEFAULT_SERVER_PORT} + + # Validate both before assigning either + if ! validate_port "$input_web" "Web port"; then + continue + fi + if ! validate_port "$input_server" "Server port"; then + continue + fi + + # Assign atomically after both validated + WEB_PORT=$input_web + SERVER_PORT=$input_server echo "${C_GREEN}Using ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}" break ;; @@ -795,12 +839,25 @@ resolve_port_conflicts() { [uU]|[uU][sS][eE]) echo "" local input_pad=$(( (TERM_COLS - 40) / 2 )) + # Collect both ports first printf "%${input_pad}s" "" read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web - WEB_PORT=${input_web:-$DEFAULT_WEB_PORT} + input_web=${input_web:-$DEFAULT_WEB_PORT} printf "%${input_pad}s" "" read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server - SERVER_PORT=${input_server:-$DEFAULT_SERVER_PORT} + input_server=${input_server:-$DEFAULT_SERVER_PORT} + + # Validate both before assigning either + if ! validate_port "$input_web" "Web port"; then + continue + fi + if ! validate_port "$input_server" "Server port"; then + continue + fi + + # Assign atomically after both validated + WEB_PORT=$input_web + SERVER_PORT=$input_server center_print "Using ports: Web=$WEB_PORT, Server=$SERVER_PORT" "$C_GREEN" break ;; @@ -850,7 +907,7 @@ launch_sequence() { case "$MODE" in web) - local url="http://localhost:$WEB_PORT" + local url="http://${APP_HOST}:$WEB_PORT" local upad=$(( (TERM_COLS - ${#url} - 10) / 2 )) echo "" printf "%${upad}s${DIM}Opening ${C_SEC}%s${RESET}\n" "" "$url" @@ -1073,10 +1130,15 @@ fi case $MODE in web) export TEST_PORT="$WEB_PORT" - export VITE_SERVER_URL="http://$HOSTNAME:$SERVER_PORT" + export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT" export PORT="$SERVER_PORT" export DATA_DIR="$SCRIPT_DIR/data" - export CORS_ORIGIN="http://localhost:$WEB_PORT,http://$HOSTNAME:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + # Always include localhost and 127.0.0.1 for local dev, plus custom hostname if different + CORS_ORIGINS="http://localhost:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + if [[ "$APP_HOST" != "localhost" && "$APP_HOST" != "127.0.0.1" ]]; then + CORS_ORIGINS="${CORS_ORIGINS},http://${APP_HOST}:$WEB_PORT" + fi + export CORS_ORIGIN="$CORS_ORIGINS" export VITE_APP_MODE="1" if [ "$PRODUCTION_MODE" = true ]; then @@ -1092,7 +1154,7 @@ case $MODE in max_retries=30 server_ready=false for ((i=0; i /dev/null 2>&1; then + if curl -s "http://localhost:$SERVER_PORT/api/health" > /dev/null 2>&1; then server_ready=true break fi @@ -1148,7 +1210,7 @@ case $MODE in center_print "✓ Server is ready!" "$C_GREEN" echo "" - center_print "The application will be available at: http://localhost:$WEB_PORT" "$C_GREEN" + center_print "The application will be available at: http://${APP_HOST}:$WEB_PORT" "$C_GREEN" echo "" # Start web app with Vite dev server (HMR enabled) From ea3930cf3dd039489d609daed978f929f92e57a6 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Mon, 19 Jan 2026 21:17:05 +0100 Subject: [PATCH 2/3] fix: convert OpenCode model format to CLI slash format (#605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: convert OpenCode model format to CLI slash format The OpenCode CLI expects models in provider/model format (e.g., opencode/big-pickle), but after commit 4b0d1399 changed model IDs from slash format to prefix format, the buildCliArgs() method was not updated to convert back to CLI format. Root cause: - Commit 4b0d1399 changed OpenCode model IDs from opencode/model to opencode-model - The old code used stripProviderPrefix() which just removed the prefix - This resulted in bare model names (e.g., "big-pickle") being passed to CLI - CLI interpreted "big-pickle" as a provider ID, causing ProviderModelNotFoundError Fix: - Updated buildCliArgs() to properly convert model formats for CLI - Bare model names (after prefix strip) now get opencode/ prepended - Models with slashes (dynamic providers) pass through unchanged Model conversion examples: - opencode-big-pickle → (stripped to) big-pickle → opencode/big-pickle - opencode-github-copilot/gpt-4o → (stripped to) github-copilot/gpt-4o → github-copilot/gpt-4o - google/gemini-2.5-pro → google/gemini-2.5-pro (unchanged) * refactor: simplify OpenCode model format conversion logic Address review feedback from Gemini Code Assist to reduce code repetition. The conditional logic for handling models with/without slashes is now unified into a simpler two-step approach: 1. Strip opencode- prefix if present 2. Prepend opencode/ if no slash exists --- apps/server/src/providers/opencode-provider.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index 6babb978..0fd8f851 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -25,7 +25,6 @@ import type { InstallationStatus, ContentBlock, } from '@automaker/types'; -import { stripProviderPrefix } from '@automaker/types'; import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform'; import { createLogger } from '@automaker/utils'; @@ -328,10 +327,18 @@ export class OpencodeProvider extends CliProvider { args.push('--format', 'json'); // Handle model selection - // Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5' + // Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx) + // OpenCode CLI expects provider/model format (e.g., 'opencode/big-model') if (options.model) { - const model = stripProviderPrefix(options.model); - args.push('--model', model); + // Strip opencode- prefix if present, then ensure slash format + const model = options.model.startsWith('opencode-') + ? options.model.slice('opencode-'.length) + : options.model; + + // If model has slash, it's already provider/model format; otherwise prepend opencode/ + const cliModel = model.includes('/') ? model : `opencode/${model}`; + + args.push('--model', cliModel); } // Note: OpenCode reads from stdin automatically when input is piped From 0d9259473eeadc4df7b60ca09784645d6316c73b Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Mon, 19 Jan 2026 22:58:47 +0100 Subject: [PATCH 3/3] fix: prevent refresh button from overlapping close button in Dev Server dialog (#610) * fix: prevent refresh button from overlapping close button in Dev Server dialog Use compact mode for DialogContent and add right padding to the header to ensure the refresh button doesn't overlap with the dialog close button. Fixes #579 * fix: restore p-0 to prevent unwanted padding from compact mode --- .../worktree-panel/components/dev-server-logs-panel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx index 02dcdb29..0e9b5e59 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx @@ -134,9 +134,10 @@ export function DevServerLogsPanel({ {/* Compact Header */} - +