diff --git a/README.md b/README.md index bcc539aa..a380165f 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,9 @@ The dashboard provides visual management for all account types: | **GLM** | API Key | `ccs glm` | Cost-optimized execution | | **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode | | **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure | +| **Minimax** | API Key | `ccs minimax` | M2 series, 1M context | +| **DeepSeek** | API Key | `ccs deepseek` | V3.2 and R1 reasoning | +| **Qwen** | API Key | `ccs qwen` | Alibaba Cloud, qwen3-coder | **OpenRouter Integration** (v7.0.0): CCS v7.0.0 adds OpenRouter with interactive model picker, dynamic discovery, and tier mapping (opus/sonnet/haiku). Create via `ccs api create --preset openrouter` or dashboard. diff --git a/package.json b/package.json index 03b9e8b5..c74eaa21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.6.0", + "version": "7.6.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index e48ff6fa..fbc42a71 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getCcsDir, getConfigPath, loadConfig } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig, @@ -144,9 +145,10 @@ function removeApiProfileUnified(name: string): void { throw new Error(`API profile not found: ${name}`); } - // Delete the settings file if it exists + // Delete the settings file if it exists. + // Uses expandPath() for cross-platform path handling. if (profile.settings) { - const settingsPath = profile.settings.replace(/^~/, os.homedir()); + const settingsPath = expandPath(profile.settings); if (fs.existsSync(settingsPath)) { fs.unlinkSync(settingsPath); } diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts index fa34b0e3..85964f0e 100644 --- a/src/api/services/provider-presets.ts +++ b/src/api/services/provider-presets.ts @@ -98,6 +98,39 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab', category: 'alternative', }, + { + id: 'minimax', + name: 'Minimax', + description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)', + baseUrl: 'https://api.minimax.io/anthropic', + defaultProfileName: 'minimax', + defaultModel: 'MiniMax-M2.1', + apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY', + apiKeyHint: 'Get your API key at platform.minimax.io', + category: 'alternative', + }, + { + id: 'deepseek', + name: 'DeepSeek', + description: 'V3.2 and R1 reasoning model (128K context)', + baseUrl: 'https://api.deepseek.com/anthropic', + defaultProfileName: 'deepseek', + defaultModel: 'deepseek-chat', + apiKeyPlaceholder: 'sk-...', + apiKeyHint: 'Get your API key at platform.deepseek.com', + category: 'alternative', + }, + { + id: 'qwen', + name: 'Qwen', + description: 'Alibaba Cloud - qwen3-coder-plus (256K context)', + baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic', + defaultProfileName: 'qwen', + defaultModel: 'qwen3-coder-plus', + apiKeyPlaceholder: 'sk-...', + apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio', + category: 'alternative', + }, ]; /** Get preset by ID */ diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 014d5407..16accd5f 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -12,7 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { findSimilarStrings } from '../utils/helpers'; +import { findSimilarStrings, expandPath } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types'; import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; @@ -62,10 +62,11 @@ export interface ProfileNotFoundError extends Error { */ /** * Load env vars from a settings file (*.settings.json). - * Expands ~ to home directory. Returns empty object on error. + * Uses expandPath() for consistent cross-platform path handling. + * Returns empty object on error. */ -function loadSettingsFromFile(settingsPath: string): Record { - const expandedPath = settingsPath.replace(/^~/, os.homedir()); +export function loadSettingsFromFile(settingsPath: string): Record { + const expandedPath = expandPath(settingsPath); try { if (!fs.existsSync(expandedPath)) return {}; const content = fs.readFileSync(expandedPath, 'utf8'); diff --git a/src/ccs.ts b/src/ccs.ts index ced3fc28..9999428b 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath, loadSettings } from './utils/config-manager'; +import { validateGlmKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; import { @@ -508,6 +509,32 @@ async function main(): Promise { // Display WebSearch status (single line, equilibrium UX) displayWebSearchStatus(); + // Pre-flight validation for GLM/GLMT profiles + if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') { + const preflightSettingsPath = getSettingsPath(profileInfo.name); + const preflightSettings = loadSettings(preflightSettingsPath); + const apiKey = preflightSettings.env?.['ANTHROPIC_AUTH_TOKEN']; + + if (apiKey) { + const validation = await validateGlmKey( + apiKey, + preflightSettings.env?.['ANTHROPIC_BASE_URL'] + ); + + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')); + process.exit(1); + } + } + } + // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { // GLMT FLOW: Settings-based with embedded proxy for thinking support diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 4bb06256..4bcc476c 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -171,4 +171,6 @@ export interface OAuthOptions { nickname?: string; /** If true, triggered from Web UI (enables project selection prompt) */ fromUI?: boolean; + /** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */ + noIncognito?: boolean; } diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index f2bb51c2..caba977c 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -126,7 +126,7 @@ export async function triggerOAuth( options: OAuthOptions = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false, add = false, nickname, fromUI = false } = options; + const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options; const callbackPort = OAUTH_PORTS[provider]; const isCLI = !fromUI; const headless = options.headless ?? isHeadlessEnvironment(); @@ -175,6 +175,10 @@ export async function triggerOAuth( if (headless) { args.push('--no-browser'); } + // Kiro-specific: --no-incognito to use normal browser (saves login credentials) + if (provider === 'kiro' && noIncognito) { + args.push('--no-incognito'); + } // Show step based on flow type if (isDeviceCodeFlow) { @@ -198,7 +202,7 @@ export async function triggerOAuth( } // Execute OAuth process - return executeOAuthProcess({ + const account = await executeOAuthProcess({ provider, binaryPath, args, @@ -210,6 +214,16 @@ export async function triggerOAuth( isCLI, nickname, }); + + // Show hint for Kiro users about --no-incognito option (first-time auth only) + if (account && provider === 'kiro' && !noIncognito) { + console.log(''); + console.log(info('Tip: To save your AWS login credentials for future sessions:')); + console.log(' Use: ccs kiro --no-incognito'); + console.log(' Or enable "Kiro: Use normal browser" in: ccs config'); + } + + return account; } /** diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 51848279..71d58eab 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -23,6 +23,7 @@ import { ProviderOAuthConfig } from './auth-types'; import { getTimeoutTroubleshooting, showStep } from './environment-detector'; import { isAuthenticated, registerAccountFromToken } from './token-manager'; import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler'; +import { OAUTH_FLOW_TYPES } from '../../management'; /** Options for OAuth process execution */ export interface OAuthProcessOptions { @@ -104,7 +105,9 @@ async function handleStdout( log(`stdout: ${output.trim()}`); state.accumulatedOutput += output; - const isDeviceCodeFlow = options.callbackPort === null; + // H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check + const flowType = OAUTH_FLOW_TYPES[options.provider] || 'authorization_code'; + const isDeviceCodeFlow = flowType === 'device_code'; // Parse project list when available if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) { @@ -112,8 +115,8 @@ async function handleStdout( log(`Parsed ${state.parsedProjects.length} projects`); } - // Handle project selection prompt - if (!state.projectPromptHandled && isProjectSelectionPrompt(output)) { + // Handle project selection prompt (Authorization Code flows only - Device Code has no stdin pipe) + if (!isDeviceCodeFlow && !state.projectPromptHandled && isProjectSelectionPrompt(output)) { state.projectPromptHandled = true; await handleProjectSelection(output, state, options, authProcess, log); } @@ -172,8 +175,8 @@ async function handleStdout( state.browserOpened = true; } - // Display OAuth URLs in headless mode (for non-device-code flows) - if (!isDeviceCodeFlow && options.headless && !state.urlDisplayed) { + // Display OAuth URL for all modes (enables VS Code terminal URL detection popup) + if (!isDeviceCodeFlow && !state.urlDisplayed) { const urlMatch = output.match(/https?:\/\/[^\s]+/); if (urlMatch) { console.log(''); @@ -258,11 +261,29 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise((resolve) => { + // H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check + const flowType = OAUTH_FLOW_TYPES[provider] || 'authorization_code'; + const isDeviceCodeFlow = flowType === 'device_code'; + + // H6: TTY detection - only inherit stdin if TTY available (prevents issues in CI/piped scripts) + // Device Code flows may need interactive stdin for email/prompts + // Authorization Code flows need piped stdin for project selection + const stdinMode = isDeviceCodeFlow && process.stdin.isTTY ? 'inherit' : 'pipe'; + const authProcess = spawn(binaryPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], + stdio: [stdinMode, 'pipe', 'pipe'], env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir }, }); + // H5: Signal handling - properly kill child process on SIGINT/SIGTERM + const cleanup = () => { + if (authProcess && !authProcess.killed) { + authProcess.kill('SIGTERM'); + } + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + const state: ProcessState = { stderrData: '', urlDisplayed: false, @@ -291,7 +312,6 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { if (isDeviceCodeFlow) { // Device Code Flow: show polling message @@ -324,6 +344,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + // H5: Remove signal handlers before killing process + process.removeListener('SIGINT', cleanup); + process.removeListener('SIGTERM', cleanup); authProcess.kill(); console.log(''); console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`)); @@ -335,6 +358,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { clearTimeout(timeout); + // H5: Remove signal handlers to prevent memory leaks + process.removeListener('SIGINT', cleanup); + process.removeListener('SIGTERM', cleanup); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); if (code === 0) { @@ -376,6 +402,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { clearTimeout(timeout); + // H5: Remove signal handlers to prevent memory leaks + process.removeListener('SIGINT', cleanup); + process.removeListener('SIGTERM', cleanup); console.log(''); console.log(fail(`Failed to start auth process: ${error.message}`)); resolve(null); diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 6152be27..758bf39b 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -250,6 +250,16 @@ export async function execClaudeWithCLIProxy( const forceConfig = argsWithoutProxy.includes('--config'); const addAccount = argsWithoutProxy.includes('--add'); const showAccounts = argsWithoutProxy.includes('--accounts'); + // Kiro-specific: browser mode for OAuth + // Default to normal browser (noIncognito=true) for reliability - incognito often fails on Linux + // --incognito flag opts into incognito mode, --no-incognito is legacy (now default) + const incognitoFlag = argsWithoutProxy.includes('--incognito'); + const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito'); + // Config setting (defaults to true = normal browser) + const kiroNoIncognitoConfig = + provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false; + // --incognito flag overrides everything to use incognito + const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig; // Parse --use flag let useAccount: string | undefined; @@ -364,6 +374,7 @@ export async function execClaudeWithCLIProxy( add: addAccount, ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), + ...(noIncognito ? { noIncognito: true } : {}), }); if (!authSuccess) { throw new Error(`Authentication required for ${providerConfig.displayName}`); @@ -591,6 +602,8 @@ export async function execClaudeWithCLIProxy( '--accounts', '--use', '--nickname', + '--incognito', + '--no-incognito', // Proxy flags are handled by resolveProxyConfig, but list for documentation ...PROXY_CLI_FLAGS, ]; diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 7e9f940e..f54d28b8 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -11,6 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; +import { expandPath } from '../utils/helpers'; import { warn } from '../utils/ui'; import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader'; @@ -145,8 +146,14 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } { * Generate UNIFIED config.yaml content for ALL providers * This enables concurrent usage of gemini/codex/agy without config conflicts. * CLIProxyAPI routes requests by model name to the appropriate provider. + * + * @param port - Server port (default: 8317) + * @param userApiKeys - User-added API keys to preserve (default: []) */ -function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string { +function generateUnifiedConfigContent( + port: number = CLIPROXY_DEFAULT_PORT, + userApiKeys: string[] = [] +): string { const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories // Convert Windows backslashes to forward slashes for YAML compatibility const authDirNormalized = authDir.split(path.sep).join('/'); @@ -154,6 +161,10 @@ function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): str // Get logging settings from user config (disabled by default) const { loggingToFile, requestLog } = getLoggingSettings(); + // Build api-keys section with internal key + preserved user keys + const allApiKeys = [CCS_INTERNAL_API_KEY, ...userApiKeys]; + const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n'); + // Unified config with enhanced CLIProxyAPI features const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} # Supports: gemini, codex, agy, qwen, iflow (concurrent usage) @@ -218,9 +229,10 @@ quota-exceeded: # Authentication # ============================================================================= -# API keys for CCS internal requests +# API keys for CCS and user-added external requests +# NOTE: User-added keys are preserved across CCS updates (fix for issue #200) api-keys: - - "${CCS_INTERNAL_API_KEY}" +${apiKeysYaml} # OAuth tokens directory (auto-discovered by CLIProxyAPI) auth-dir: "${authDirNormalized}" @@ -255,24 +267,79 @@ export function generateConfig( } /** - * Force regenerate config.yaml with latest settings - * Deletes existing config and creates fresh one with current port + * Parse user-added API keys from existing config content. + * Extracts all keys except the internal CCS key for preservation. + * + * @param content - Existing config.yaml content + * @returns Array of user-added API keys (excludes CCS_INTERNAL_API_KEY) + */ +export function parseUserApiKeys(content: string): string[] { + const userKeys: string[] = []; + + // Find the api-keys section by looking for lines starting with " - " after "api-keys:" + // Normalize line endings first + const normalizedContent = content.replace(/\r\n/g, '\n'); + + // Find the api-keys: line and extract all subsequent key entries + const lines = normalizedContent.split('\n'); + let inApiKeysSection = false; + + for (const line of lines) { + // Check if this is the start of api-keys section + if (line.match(/^api-keys:\s*$/)) { + inApiKeysSection = true; + continue; + } + + // If we're in the api-keys section, look for key entries + if (inApiKeysSection) { + // Key entries are indented with " - " or similar + const keyMatch = line.match(/^\s+-\s*"([^"]*)"/); + if (keyMatch) { + const key = keyMatch[1]; + // Exclude the internal CCS key and empty strings + if (key && key !== CCS_INTERNAL_API_KEY) { + userKeys.push(key); + } + } else if (line.match(/^\S/) && line.trim().length > 0) { + // Non-indented line that's not empty means we've left the api-keys section + break; + } + // Continue for blank lines or other indented content + } + } + + return userKeys; +} + +/** + * Force regenerate config.yaml with latest settings. + * Preserves user-added API keys and port settings. + * + * @param port - Default port to use if not found in existing config * @returns Path to new config file */ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { const configPath = getConfigPath(); - // Read existing port if config exists (preserve user's port choice) + // Preserve user settings from existing config let effectivePort = port; + let userApiKeys: string[] = []; + if (fs.existsSync(configPath)) { try { const content = fs.readFileSync(configPath, 'utf-8'); + + // Preserve port setting const portMatch = content.match(/^port:\s*(\d+)/m); if (portMatch) { effectivePort = parseInt(portMatch[1], 10); } + + // Preserve user-added API keys (fix for issue #200) + userApiKeys = parseUserApiKeys(content); } catch { - // Use default port if reading fails + // Use defaults if reading fails } // Delete existing config fs.unlinkSync(configPath); @@ -282,8 +349,8 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 }); - // Generate fresh config - const configContent = generateUnifiedConfigContent(effectivePort); + // Generate fresh config with preserved user API keys + const configContent = generateUnifiedConfigContent(effectivePort, userApiKeys); fs.writeFileSync(configPath, configContent, { mode: 0o600 }); return configPath; @@ -463,7 +530,7 @@ export function getEffectiveEnvVars( // Priority 1: Custom settings path (for user-defined variants) if (customSettingsPath) { - const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); + const expandedPath = expandPath(customSettingsPath); if (fs.existsSync(expandedPath)) { try { const content = fs.readFileSync(expandedPath, 'utf-8'); @@ -569,7 +636,7 @@ export function getRemoteEnvVars( // Priority 1: Custom settings path (for user-defined variants) if (customSettingsPath) { - const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); + const expandedPath = expandPath(customSettingsPath); if (fs.existsSync(expandedPath)) { try { const content = fs.readFileSync(expandedPath, 'utf-8'); diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index e42705be..6381dc93 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -55,6 +55,7 @@ export { generateConfig, regenerateConfig, configNeedsRegeneration, + parseUserApiKeys, getClaudeEnvVars, getEffectiveEnvVars, getProviderSettingsPath, diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index b3c4b185..47afe52f 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import * as os from 'os'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { getCcsDir } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIProxyProvider } from '../types'; @@ -124,10 +125,11 @@ export function createSettingsFileUnified( } /** - * Delete settings file if it exists + * Delete settings file if it exists. + * Uses expandPath() for cross-platform path handling. */ export function deleteSettingsFile(settingsPath: string): boolean { - const resolvedPath = settingsPath.replace(/^~/, os.homedir()); + const resolvedPath = expandPath(settingsPath); if (fs.existsSync(resolvedPath)) { fs.unlinkSync(resolvedPath); return true; diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 076eeb6c..06dfa95f 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -426,7 +426,7 @@ async function showHelp(): Promise { console.log(''); console.log(subheader('Options')); console.log( - ` ${color('--preset ', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry)` + ` ${color('--preset ', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry, minimax, deepseek, qwen)` ); console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); @@ -442,6 +442,11 @@ async function showHelp(): Promise { console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`); console.log(` ${color('kimi', 'command')} Kimi - Moonshot AI reasoning model`); console.log(` ${color('foundry', 'command')} Azure Foundry - Claude via Microsoft Azure`); + console.log(` ${color('minimax', 'command')} Minimax - M2 series with 1M context`); + console.log(` ${color('deepseek', 'command')} DeepSeek - V3.2 and R1 reasoning (128K)`); + console.log( + ` ${color('qwen', 'command')} Qwen - Alibaba Cloud qwen3-coder-plus (256K)` + ); console.log(''); console.log(subheader('Examples')); console.log(` ${dim('# Interactive wizard')}`); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 224230ae..82f70a86 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -173,6 +173,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], + ['ccs kiro --incognito', 'Use incognito browser (default: normal)'], ['ccs codex "explain code"', 'Use with prompt'], ] ); diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index dc0c5801..5b7acf5a 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -13,6 +13,8 @@ */ import * as readline from 'readline'; +import * as fs from 'fs'; +import * as path from 'path'; import { initUI, header, ok, info, warn } from '../utils/ui'; import { loadOrCreateUnifiedConfig, @@ -21,6 +23,7 @@ import { hasUnifiedConfig, } from '../config/unified-config-loader'; import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; +import { getCcsDir } from '../utils/config-manager'; /** Custom error for user cancellation (Ctrl+C) */ class UserCancelledError extends Error { @@ -112,39 +115,75 @@ async function selectOption( /** * Check if this is a first-time install (config exists but is empty/unconfigured) * Returns true if user should be prompted to run setup wizard + * + * IMPORTANT: Also checks legacy config.json for existing profiles to avoid + * treating users with existing GLM/Kimi setups as "first-time installs" + * (Fix for issue #195 - GLM auth persistence regression) */ export function isFirstTimeInstall(): boolean { - // No config at all → definitely first time - if (!hasUnifiedConfig()) { - return true; - } + // Check unified config first (config.yaml) + if (hasUnifiedConfig()) { + const loaded = loadUnifiedConfig(); - // Try loading config directly to detect corruption - const loaded = loadUnifiedConfig(); - if (loaded === null) { // Config exists but is corrupted/invalid - don't treat as first-time - // User should fix or delete the file, or use --force - console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted')); - console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose')); - return false; + if (loaded === null) { + console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted')); + console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose')); + return false; + } + + // Check for any meaningful configuration in unified config + const hasProfiles = Object.keys(loaded.profiles || {}).length > 0; + const hasAccounts = Object.keys(loaded.accounts || {}).length > 0; + const hasVariants = Object.keys(loaded.cliproxy?.variants || {}).length > 0; + const hasOAuthAccounts = Object.keys(loaded.cliproxy?.oauth_accounts || {}).length > 0; + const hasRemoteProxy = + loaded.cliproxy_server?.remote?.enabled && loaded.cliproxy_server?.remote?.host; + + // If any of these exist in unified config, user has configured something + if (hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy) { + return false; + } } - // Config exists and is valid - check if it's meaningfully configured - const config = loaded; + // Also check legacy config.json for existing profiles + // This prevents treating users with GLM/Kimi in config.json as "first-time installs" + const ccsDir = getCcsDir(); + const legacyConfigPath = path.join(ccsDir, 'config.json'); - // Check for any meaningful configuration - const hasProfiles = Object.keys(config.profiles || {}).length > 0; - const hasAccounts = Object.keys(config.accounts || {}).length > 0; - const hasVariants = Object.keys(config.cliproxy?.variants || {}).length > 0; - const hasOAuthAccounts = Object.keys(config.cliproxy?.oauth_accounts || {}).length > 0; - const hasRemoteProxy = - config.cliproxy_server?.remote?.enabled && config.cliproxy_server?.remote?.host; + if (fs.existsSync(legacyConfigPath)) { + try { + const content = fs.readFileSync(legacyConfigPath, 'utf8'); + const legacyConfig = JSON.parse(content) as { profiles?: Record }; - // If any of these exist, user has configured something - const isConfigured = - hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy; + if (legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0) { + // Has legacy profiles - NOT first time + return false; + } + } catch { + // Legacy config exists but is invalid - ignore and continue + } + } - return !isConfigured; + // Also check profiles.json for existing accounts + const legacyProfilesPath = path.join(ccsDir, 'profiles.json'); + + if (fs.existsSync(legacyProfilesPath)) { + try { + const content = fs.readFileSync(legacyProfilesPath, 'utf8'); + const legacyProfiles = JSON.parse(content) as { profiles?: Record }; + + if (legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0) { + // Has legacy accounts - NOT first time + return false; + } + } catch { + // Legacy profiles exists but is invalid - ignore and continue + } + } + + // No meaningful configuration found anywhere + return true; } /** diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 142c57e6..9fa347d4 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -15,9 +15,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; +import { expandPath } from '../utils/helpers'; import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unified-config-types'; import { createEmptyUnifiedConfig } from './unified-config-types'; -import { saveUnifiedConfig, hasUnifiedConfig } from './unified-config-loader'; +import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader'; import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; @@ -44,6 +45,58 @@ export function needsMigration(): boolean { return hasOldConfig && !hasNewConfig; } +/** + * Pre-loaded config data for TOCTOU-safe migration checks. + * Read once, use for both check and migration. + */ +export interface MigrationCheckData { + legacyConfig: Record | null; + unifiedConfig: ReturnType; + needsMigration: boolean; +} + +/** + * Load config files once for TOCTOU-safe migration operations. + * Returns data that can be passed to migration functions. + */ +export function loadMigrationCheckData(): MigrationCheckData { + const ccsDir = getCcsDir(); + const configJsonPath = path.join(ccsDir, 'config.json'); + + const legacyConfig = fs.existsSync(configJsonPath) ? readJsonSafe(configJsonPath) : null; + const unifiedConfig = loadUnifiedConfig(); + + // Determine if migration is needed + let needsMigration = false; + + if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) { + const legacyProfiles = legacyConfig.profiles as Record; + for (const profileName of Object.keys(legacyProfiles)) { + if (!unifiedConfig.profiles[profileName]) { + needsMigration = true; + break; + } + } + } + + return { legacyConfig, unifiedConfig, needsMigration }; +} + +/** + * Check if there are legacy profiles that haven't been migrated to config.yaml. + * This catches the case where config.yaml exists but is empty/missing profiles + * that are still in config.json. + * + * Used by autoMigrate() to trigger inline migration when needed. + * (Fix for issue #195 - GLM auth persistence regression) + * + * Note: For TOCTOU-safe operations, use loadMigrationCheckData() instead. + */ +export function needsProfileMigration(): boolean { + const data = loadMigrationCheckData(); + return data.needsMigration; +} + /** * Get list of backup directories. */ @@ -167,7 +220,13 @@ export async function migrate(dryRun = false): Promise { } } - // 7. Migrate cache files + // 7. Write new config FIRST (before moving files - H8 fix) + // Note: Settings remain in *.settings.json files, config.yaml only stores references + if (!dryRun) { + saveUnifiedConfig(unifiedConfig); + } + + // 8. Migrate cache files AFTER config is saved (H8: prevents inconsistent state) if (!dryRun) { const cacheDir = path.join(ccsDir, 'cache'); if (!fs.existsSync(cacheDir)) { @@ -188,12 +247,6 @@ export async function migrate(dryRun = false): Promise { } } - // 8. Write new config (unless dry run) - // Note: Settings remain in *.settings.json files, config.yaml only stores references - if (!dryRun) { - saveUnifiedConfig(unifiedConfig); - } - return { success: true, backupPath: dryRun ? undefined : backupDir, @@ -270,13 +323,6 @@ function readJsonSafe(filePath: string): Record | null { } } -/** - * Expand ~ to home directory in path. - */ -function expandPath(p: string): string { - return p.replace(/^~/, process.env.HOME || ''); -} - /** * Move file if it exists. Returns true if moved, false if source didn't exist. */ @@ -326,6 +372,9 @@ async function performBackup(srcDir: string, backupDir: string): Promise { * Auto-migrate on first run after update. * Silent if already migrated or no config exists. * Shows friendly message with backup location on success. + * + * Also handles inline profile migration when config.yaml exists but is missing + * profiles from config.json (Fix for issue #195). */ export async function autoMigrate(): Promise { // Skip in test environment @@ -333,30 +382,122 @@ export async function autoMigrate(): Promise { return; } - // Skip if no migration needed - if (!needsMigration()) { + // Check if full migration is needed (no config.yaml exists) + if (needsMigration()) { + const result = await migrate(false); + + if (result.success) { + console.log(''); + console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS')); + console.log(` Backup: ${result.backupPath}`); + console.log(` Items: ${result.migratedFiles.length} migrated`); + if (result.warnings.length > 0) { + for (const warning of result.warnings) { + console.log(warn(warning)); + } + } + console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); + console.log(''); + } else { + console.log(''); + console.log(infoBox('Migration failed - using legacy config', 'WARNING')); + console.log(` Error: ${result.error}`); + console.log(' Retry: ccs migrate'); + console.log(''); + } return; } - const result = await migrate(false); - - if (result.success) { - console.log(''); - console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS')); - console.log(` Backup: ${result.backupPath}`); - console.log(` Items: ${result.migratedFiles.length} migrated`); - if (result.warnings.length > 0) { - for (const warning of result.warnings) { - console.log(warn(warning)); + // Check if inline profile migration is needed (config.yaml exists but missing profiles) + // CRIT-2: Load data once and pass to migrateProfilesToUnified to avoid TOCTOU race + const migrationData = loadMigrationCheckData(); + if (migrationData.needsMigration) { + const result = await migrateProfilesToUnified(migrationData); + if (result.success && result.migratedFiles.length > 0) { + console.log(''); + console.log(infoBox('Migrated legacy profiles to config.yaml', 'SUCCESS')); + console.log(` Profiles: ${result.migratedFiles.join(', ')}`); + // H7: Show collision warnings if any + if (result.warnings.length > 0) { + for (const warning of result.warnings) { + console.log(warn(warning)); + } } + console.log(''); } - console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); - console.log(''); - } else { - console.log(''); - console.log(infoBox('Migration failed - using legacy config', 'WARNING')); - console.log(` Error: ${result.error}`); - console.log(' Retry: ccs migrate'); - console.log(''); + } +} + +/** + * Migrate only profiles from config.json to existing config.yaml. + * Used when config.yaml exists but is missing profiles. + * + * @param preloadedData - Optional pre-loaded config data from loadMigrationCheckData() + * for TOCTOU-safe operations. If not provided, reads configs fresh. + */ +async function migrateProfilesToUnified( + preloadedData?: MigrationCheckData +): Promise { + const ccsDir = getCcsDir(); + const migratedFiles: string[] = []; + const warnings: string[] = []; + + try { + // Use preloaded data if available (CRIT-2: TOCTOU fix) + const oldConfig = preloadedData?.legacyConfig ?? readJsonSafe(path.join(ccsDir, 'config.json')); + const unifiedConfig = preloadedData?.unifiedConfig ?? loadUnifiedConfig(); + + if (!oldConfig?.profiles || !unifiedConfig) { + return { success: true, migratedFiles, warnings }; + } + + let modified = false; + + // Migrate API profiles from config.json + for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) { + const pathStr = settingsPath as string; + + // H7: Detect collision - profile exists in both configs + if (unifiedConfig.profiles[name]) { + // Check if settings differ (potential data loss) + const existingSettings = unifiedConfig.profiles[name].settings; + if (existingSettings && existingSettings !== pathStr) { + warnings.push( + `Profile "${name}" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy (${pathStr})` + ); + } + continue; + } + + const expandedPath = expandPath(pathStr); + + // Verify settings file exists + if (!fs.existsSync(expandedPath)) { + warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`); + continue; + } + + // Store reference to settings file + unifiedConfig.profiles[name] = { + type: 'api', + settings: pathStr, + }; + migratedFiles.push(name); + modified = true; + } + + // Save if modified + if (modified) { + saveUnifiedConfig(unifiedConfig); + } + + return { success: true, migratedFiles, warnings }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + migratedFiles, + warnings, + }; } } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 333ef307..53760348 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -89,6 +89,8 @@ export interface CLIProxyConfig { variants: Record; /** Logging configuration (disabled by default) */ logging?: CLIProxyLoggingConfig; + /** Kiro: disable incognito browser mode (use normal browser to save credentials) */ + kiro_no_incognito?: boolean; } /** diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts index 9edb832d..d1170f6e 100644 --- a/src/glmt/glmt-proxy.ts +++ b/src/glmt/glmt-proxy.ts @@ -587,7 +587,14 @@ export class GlmtProxy { statusCode: 401, type: 'authentication_error', message: - 'Authorization token missing or invalid. Please check your ANTHROPIC_AUTH_TOKEN environment variable in ~/.ccs/config.json or settings.json.', + 'API key rejected by Z.AI. This can happen when:\n' + + ' 1. Key expired on Z.AI server (most common after idle periods)\n' + + ' 2. Key was revoked or regenerated\n' + + ' 3. Key is missing or malformed\n\n' + + 'To fix:\n' + + ' 1. Go to Z.AI dashboard and regenerate your API key\n' + + ' 2. Update ~/.ccs/glm.settings.json with the new key\n' + + ' 3. Or run: ccs config -> API Profiles -> GLM -> Update key', }; } diff --git a/src/types/index.ts b/src/types/index.ts index 30fec139..648ae97f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,5 +46,5 @@ export type { } from './glmt'; // Utility types -export { ErrorCode, LogLevel } from './utils'; -export type { ColorName, TerminalInfo, Result } from './utils'; +export { LogLevel } from './utils'; +export type { ErrorCode, ColorName, TerminalInfo, Result } from './utils'; diff --git a/src/types/utils.ts b/src/types/utils.ts index a9320d53..51ccc6a7 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -3,7 +3,8 @@ */ // Re-export from error-codes for consistency -export { ERROR_CODES, ErrorCode, getErrorDocUrl, getErrorCategory } from '../utils/error-codes'; +export { ERROR_CODES, getErrorDocUrl, getErrorCategory } from '../utils/error-codes'; +export type { ErrorCode } from '../utils/error-codes'; /** * Log levels diff --git a/src/utils/api-key-validator.ts b/src/utils/api-key-validator.ts new file mode 100644 index 00000000..cb732566 --- /dev/null +++ b/src/utils/api-key-validator.ts @@ -0,0 +1,130 @@ +/** + * API Key Pre-flight Validator + * + * Quick validation of API keys before Claude CLI launch. + * Catches expired keys early with actionable error messages. + */ + +import * as http from 'http'; +import * as https from 'https'; +import { URL } from 'url'; + +export interface ValidationResult { + valid: boolean; + error?: string; + suggestion?: string; +} + +/** Default placeholders that indicate unconfigured keys */ +const DEFAULT_PLACEHOLDERS = [ + 'YOUR_GLM_API_KEY_HERE', + 'YOUR_KIMI_API_KEY_HERE', + 'YOUR_API_KEY_HERE', + 'YOUR-API-KEY-HERE', + 'PLACEHOLDER', + '', +]; + +/** + * Validate GLM API key with quick health check + * + * @param apiKey - The ANTHROPIC_AUTH_TOKEN value + * @param baseUrl - Optional base URL (defaults to Z.AI) + * @param timeoutMs - Timeout in milliseconds (default 2000) + */ +export async function validateGlmKey( + apiKey: string, + baseUrl?: string, + timeoutMs = 2000 +): Promise { + // Skip if disabled + if (process.env.CCS_SKIP_PREFLIGHT === '1') { + return { valid: true }; + } + + // Basic format check - detect placeholders + if (!apiKey || DEFAULT_PLACEHOLDERS.includes(apiKey.toUpperCase())) { + return { + valid: false, + error: 'API key not configured', + suggestion: + 'Set ANTHROPIC_AUTH_TOKEN in ~/.ccs/glm.settings.json\n' + + 'Or run: ccs config -> API Profiles -> GLM', + }; + } + + // Determine validation endpoint + // Z.AI uses /api/anthropic path, we can test with a minimal request + const targetBase = baseUrl || 'https://api.z.ai'; + let url: URL; + try { + url = new URL('/api/anthropic/v1/models', targetBase); + } catch { + // Invalid URL - fail-open + return { valid: true }; + } + + return new Promise((resolve) => { + // Determine protocol - use http module for http:// URLs + const isHttps = url.protocol === 'https:'; + const httpModule = isHttps ? https : http; + const defaultPort = isHttps ? 443 : 80; + + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || defaultPort, + path: url.pathname, + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'User-Agent': 'CCS-Preflight/1.0', + }, + }; + + const req = httpModule.request(options, (res) => { + clearTimeout(timeoutId); + + if (res.statusCode === 200) { + resolve({ valid: true }); + } else if (res.statusCode === 401 || res.statusCode === 403) { + resolve({ + valid: false, + error: 'API key rejected by Z.AI', + suggestion: + 'Your key may have expired. To fix:\n' + + ' 1. Go to Z.AI dashboard and regenerate your API key\n' + + ' 2. Update ~/.ccs/glm.settings.json with the new key\n' + + ' 3. Or run: ccs config -> API Profiles -> GLM', + }); + } else { + // Other errors (404, 500, etc.) - fail-open, let Claude CLI handle + // Debug log for diagnostics when CCS_DEBUG is set + if (process.env.CCS_DEBUG === '1') { + console.error( + `[CCS-Preflight] Unexpected status ${res.statusCode} from ${url.href} - fail-open` + ); + } + resolve({ valid: true }); + } + + // Consume response body to free resources + res.resume(); + }); + + req.on('error', () => { + clearTimeout(timeoutId); + // Network error - fail-open + resolve({ valid: true }); + }); + + // Set timeout after request is created so we can destroy it on timeout + const timeoutId = setTimeout(() => { + // Abort request to prevent TCP connection leak + req.destroy(); + // Fail-open on timeout - let Claude CLI handle it + resolve({ valid: true }); + }, timeoutMs); + + req.end(); + }); +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 5f1f9031..f281f7e6 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -14,9 +14,14 @@ export function error(message: string): never { * Path expansion (~ and env vars) */ export function expandPath(pathStr: string): string { + // Normalize separators first to handle mixed paths + pathStr = pathStr.replace(/\\/g, '/'); + // Handle tilde expansion - if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) { + if (pathStr.startsWith('~/')) { pathStr = path.join(os.homedir(), pathStr.slice(2)); + } else if (pathStr === '~') { + pathStr = os.homedir(); } // Expand environment variables (Windows and Unix) diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 1abf36d2..637cf2d3 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -23,6 +23,7 @@ import { } from '../../cliproxy/account-manager'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import type { CLIProxyProvider } from '../../cliproxy/types'; const router = Router(); @@ -268,7 +269,7 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v */ router.post('/:provider/start', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { nickname } = req.body; + const { nickname, noIncognito: noIncognitoBody } = req.body; // Validate provider if (!validProviders.includes(provider as CLIProxyProvider)) { @@ -276,6 +277,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise { + const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`); + + beforeEach(() => { + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('loadSettingsFromFile', () => { + it('should load settings from a valid JSON file', () => { + const settingsPath = path.join(tempDir, 'valid.settings.json'); + const settings = { env: { KEY: 'VALUE' } }; + fs.writeFileSync(settingsPath, JSON.stringify(settings)); + + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({ KEY: 'VALUE' }); + }); + + it('should return empty object for non-existent file', () => { + const result = loadSettingsFromFile(path.join(tempDir, 'non-existent.json')); + expect(result).toEqual({}); + }); + + it('should return empty object for invalid JSON', () => { + const settingsPath = path.join(tempDir, 'invalid.json'); + fs.writeFileSync(settingsPath, 'invalid json'); + + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({}); + }); + + it('should handle tilde expansion correctly', () => { + const mockHome = path.join(tempDir, 'home'); + fs.mkdirSync(mockHome, { recursive: true }); + const settingsPath = '~/test.settings.json'; + const actualPath = path.join(mockHome, 'test.settings.json'); + fs.writeFileSync(actualPath, JSON.stringify({ env: { HOME_VAR: 'TRUE' } })); + + // Mock os.homedir + const homedirSpy = spyOn(os, 'homedir').mockReturnValue(mockHome); + + try { + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({ HOME_VAR: 'TRUE' }); + } finally { + homedirSpy.mockRestore(); + } + }); + + it('should handle env var expansion correctly', () => { + const settingsPath = path.join(tempDir, '${TEST_VAR}.json'); + const actualPath = path.join(tempDir, 'actual.json'); + fs.writeFileSync(actualPath, JSON.stringify({ env: { ENV_VAR: 'EXPANDED' } })); + + process.env.TEST_VAR = 'actual'; + try { + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({ ENV_VAR: 'EXPANDED' }); + } finally { + delete process.env.TEST_VAR; + } + }); + }); + + describe('detectProfileType', () => { + let detector: ProfileDetector; + + beforeEach(() => { + detector = new ProfileDetector(); + }); + + it('should detect CLIProxy profiles', () => { + const result = detector.detectProfileType('gemini'); + expect(result.type).toBe('cliproxy'); + expect(result.name).toBe('gemini'); + expect(result.provider).toBe('gemini'); + }); + + it('should detect settings-based profile from unified config', () => { + const settingsPath = path.join(tempDir, 'glm.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } })); + + const mockUnifiedConfig = { + version: 2, + profiles: { + glm: { settings: settingsPath, type: 'api' } + } + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any); + + try { + const result = detector.detectProfileType('glm'); + expect(result.type).toBe('settings'); + expect(result.name).toBe('glm'); + expect(result.env).toEqual({ ANTHROPIC_MODEL: 'glm-4' }); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should detect account-based profile from unified config', () => { + const mockUnifiedConfig = { + version: 2, + accounts: { + work: { created: '2025-01-01', last_used: '2025-01-02' } + } + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any); + + try { + const result = detector.detectProfileType('work'); + expect(result.type).toBe('account'); + expect(result.name).toBe('work'); + expect(result.profile).toBeDefined(); + expect((result.profile as any).type).toBe('account'); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should return null for unknown profile (throws error)', () => { + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + // Mock readConfig/readProfiles to return empty + const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false); + + try { + expect(() => detector.detectProfileType('unknown')).toThrow(/Profile not found/); + } finally { + isUnifiedModeSpy.mockRestore(); + existsSyncSpy.mockRestore(); + } + }); + }); +}); diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 91dd4b0e..eab0743d 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -166,6 +166,365 @@ describe('Config Generator', () => { }); }); + // ========================================================================= + // API Key Preservation Tests (Issue #200) + // ========================================================================= + describe('parseUserApiKeys', () => { + let parseUserApiKeys; + + beforeEach(() => { + // Clear cache and reload module + delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')]; + const configGenerator = require('../../../dist/cliproxy/config-generator'); + parseUserApiKeys = configGenerator.parseUserApiKeys; + }); + + it('extracts user-added API keys from config content', () => { + const configContent = ` +# CLIProxyAPI config generated by CCS v4 +port: 8317 + +api-keys: + - "ccs-internal-managed" + - "user-custom-key-12345" + - "another-user-key-abcde" + +auth-dir: "/home/user/.ccs/cliproxy/auth" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-custom-key-12345', 'another-user-key-abcde']); + }); + + it('excludes the internal CCS key', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "my-custom-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.strictEqual(userKeys.length, 1); + assert.strictEqual(userKeys[0], 'my-custom-key'); + assert(!userKeys.includes('ccs-internal-managed'), 'Should not include internal key'); + }); + + it('returns empty array when only internal key exists', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, []); + }); + + it('returns empty array when no api-keys section exists', () => { + const configContent = ` +port: 8317 +debug: false +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, []); + }); + + it('handles single user key', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "single-user-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['single-user-key']); + }); + + it('handles multiple user keys', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "key1" + - "key2" + - "key3" + - "key4" + - "key5" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['key1', 'key2', 'key3', 'key4', 'key5']); + }); + + it('handles keys with special characters', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "sk_live_1234567890abcdef" + - "api-key-with-dashes" + - "key_with_underscores_123" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, [ + 'sk_live_1234567890abcdef', + 'api-key-with-dashes', + 'key_with_underscores_123', + ]); + }); + + it('preserves key order', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "zebra-key" + - "alpha-key" + - "middle-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['zebra-key', 'alpha-key', 'middle-key']); + }); + + it('handles empty string key (edge case)', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "" + - "valid-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + // Empty strings should be filtered out (only truthy values) + assert.deepStrictEqual(userKeys, ['valid-key']); + }); + + it('handles config with Windows line endings', () => { + const configContent = + 'api-keys:\r\n - "ccs-internal-managed"\r\n - "user-key"\r\n\r\nauth-dir: "/test"\r\n'; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-key']); + }); + + it('handles different indentation styles', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "user-key-with-4-spaces" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-key-with-4-spaces']); + }); + + it('handles tabs in indentation', () => { + const configContent = 'api-keys:\n\t- "ccs-internal-managed"\n\t- "user-key-with-tab"\n\nauth-dir: "/test"'; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-key-with-tab']); + }); + }); + + describe('regenerateConfig API key preservation', () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + + let testDir; + let originalCcsHome; + let regenerateConfig; + let getConfigPath; + + beforeEach(() => { + // Create a temporary test directory + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = testDir; + + // Clear cache and reload module + delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')]; + delete require.cache[require.resolve('../../../dist/utils/config-manager')]; + const configGenerator = require('../../../dist/cliproxy/config-generator'); + regenerateConfig = configGenerator.regenerateConfig; + getConfigPath = configGenerator.getConfigPath; + }); + + afterEach(() => { + // Restore environment + process.env.CCS_HOME = originalCcsHome; + + // Clean up test directory + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('preserves user API keys during regeneration', () => { + // Create initial config with user keys + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v3 +port: 8317 + +api-keys: + - "ccs-internal-managed" + - "user-custom-key-12345" + - "another-user-key" + +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + // Regenerate config (simulating CCS update) + regenerateConfig(); + + // Read regenerated config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + // Verify user keys are preserved + assert(newConfig.includes('user-custom-key-12345'), 'Should preserve first user key'); + assert(newConfig.includes('another-user-key'), 'Should preserve second user key'); + assert(newConfig.includes('ccs-internal-managed'), 'Should include internal key'); + }); + + it('preserves port setting during regeneration', () => { + // Create initial config with custom port + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v3 +port: 9999 + +api-keys: + - "ccs-internal-managed" + +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + // Regenerate config + regenerateConfig(); + + // Read regenerated config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + // Verify port is preserved + assert(newConfig.includes('port: 9999'), 'Should preserve custom port'); + }); + + it('creates fresh config when none exists', () => { + // Ensure clean state + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + const configPath = path.join(cliproxyDir, 'config.yaml'); + + assert(!fs.existsSync(configPath), 'Config should not exist initially'); + + // Generate config + regenerateConfig(); + + // Verify config was created + assert(fs.existsSync(configPath), 'Config should be created'); + + const config = fs.readFileSync(configPath, 'utf-8'); + assert(config.includes('ccs-internal-managed'), 'Should include internal key'); + assert(config.includes('port: 8317'), 'Should use default port'); + }); + + it('handles corrupted config gracefully', () => { + // Create corrupted config + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), 'invalid yaml content: [[['); + + // Should not throw + assert.doesNotThrow(() => regenerateConfig()); + + // Should create valid config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert(newConfig.includes('ccs-internal-managed'), 'Should create valid config'); + }); + + it('handles empty config file gracefully', () => { + // Create empty config + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), ''); + + // Should not throw + assert.doesNotThrow(() => regenerateConfig()); + + // Should create valid config with defaults + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert(newConfig.includes('ccs-internal-managed'), 'Should create valid config'); + assert(newConfig.includes('port: 8317'), 'Should use default port'); + }); + + it('preserves multiple user keys in correct order', () => { + // Create config with multiple user keys + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v3 +port: 8317 + +api-keys: + - "ccs-internal-managed" + - "key-first" + - "key-second" + - "key-third" + +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + // Regenerate config + regenerateConfig(); + + // Read regenerated config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + // Verify order is preserved (first > second > third) + const firstPos = newConfig.indexOf('key-first'); + const secondPos = newConfig.indexOf('key-second'); + const thirdPos = newConfig.indexOf('key-third'); + + assert(firstPos < secondPos, 'First key should come before second'); + assert(secondPos < thirdPos, 'Second key should come before third'); + }); + }); + describe('Cross-platform path handling', () => { it('normalizes paths regardless of current platform', () => { // The fix should work consistently on Windows, macOS, and Linux diff --git a/tests/unit/commands/setup-command.test.ts b/tests/unit/commands/setup-command.test.ts new file mode 100644 index 00000000..e4bb37b4 --- /dev/null +++ b/tests/unit/commands/setup-command.test.ts @@ -0,0 +1,133 @@ +/** + * Unit tests for setup-command.ts - isFirstTimeInstall() function + * + * Issue #195: GLM auth regression - isFirstTimeInstall() was ignoring legacy configs + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Create temp directory for test isolation +let testDir: string; + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-')); +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +// Helper to create config files +function createConfigYaml(content: string): void { + fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8'); +} + +function createConfigJson(content: object): void { + fs.writeFileSync(path.join(testDir, 'config.json'), JSON.stringify(content, null, 2), 'utf8'); +} + +function createProfilesJson(content: object): void { + fs.writeFileSync(path.join(testDir, 'profiles.json'), JSON.stringify(content, null, 2), 'utf8'); +} + +describe('isFirstTimeInstall logic', () => { + describe('legacy config detection', () => { + it('should detect legacy config.json with profiles', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createConfigJson({ profiles: { glm: '~/.ccs/glm.settings.json' } }); + + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + const hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0; + + expect(hasLegacyProfiles).toBe(true); + }); + + it('should detect legacy profiles.json with accounts', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createProfilesJson({ profiles: { work: { path: '/some/path' } } }); + + const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8')); + const hasLegacyAccounts = legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0; + + expect(hasLegacyAccounts).toBe(true); + }); + + it('should return true when no configs exist', () => { + const hasConfigYaml = fs.existsSync(path.join(testDir, 'config.yaml')); + const hasConfigJson = fs.existsSync(path.join(testDir, 'config.json')); + const hasProfilesJson = fs.existsSync(path.join(testDir, 'profiles.json')); + + expect(hasConfigYaml).toBe(false); + expect(hasConfigJson).toBe(false); + expect(hasProfilesJson).toBe(false); + }); + + it('should detect unified config with profiles', () => { + createConfigYaml(` +version: 2 +profiles: + glm: + type: api + settings: ~/.ccs/glm.settings.json +accounts: {} +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + const hasProfiles = content.includes('glm:') && content.includes('type: api'); + + expect(hasProfiles).toBe(true); + }); + + it('should detect unified config with remote proxy', () => { + createConfigYaml(` +version: 2 +profiles: {} +accounts: {} +cliproxy_server: + remote: + enabled: true + host: my-server.example.com +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + const hasRemoteProxy = content.includes('enabled: true') && content.includes('host:'); + + expect(hasRemoteProxy).toBe(true); + }); + }); + + describe('empty config handling', () => { + it('should treat empty configs as first-time', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createConfigJson({ profiles: {} }); + createProfilesJson({ profiles: {} }); + + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8')); + + const hasLegacyProfiles = Object.keys(legacyConfig.profiles || {}).length > 0; + const hasLegacyAccounts = Object.keys(legacyProfiles.profiles || {}).length > 0; + + expect(hasLegacyProfiles).toBe(false); + expect(hasLegacyAccounts).toBe(false); + }); + }); + + describe('corrupted config handling', () => { + it('should handle corrupted config.json gracefully', () => { + fs.writeFileSync(path.join(testDir, 'config.json'), 'not valid json{{{', 'utf8'); + + let hasLegacyProfiles = false; + try { + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0; + } catch { + hasLegacyProfiles = false; + } + + expect(hasLegacyProfiles).toBe(false); + }); + }); +}); diff --git a/tests/unit/utils/expand-path.test.ts b/tests/unit/utils/expand-path.test.ts new file mode 100644 index 00000000..42dab0f5 --- /dev/null +++ b/tests/unit/utils/expand-path.test.ts @@ -0,0 +1,104 @@ +import { expect, test, describe, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import * as os from "os"; +import { expandPath } from "../../../src/utils/helpers"; + +describe("expandPath", () => { + const originalEnv = { ...process.env }; + const HOME = os.homedir(); + + beforeEach(() => { + process.env.TEST_HOME = "/custom/home"; + process.env.TEST_VAR = "foo"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + test("1. Tilde expansion: ~/path -> /home/user/path", () => { + expect(expandPath("~/test/file.txt")).toBe(path.join(HOME, "test/file.txt")); + }); + + test("2. Windows tilde with backslash: ~\\path", () => { + // expandPath handles both ~/ and ~\ regardless of platform + expect(expandPath("~\\test\\file.txt")).toBe(path.join(HOME, "test/file.txt")); + }); + + test("3. Environment variable expansion: ${VAR}/path", () => { + expect(expandPath("${TEST_HOME}/file.txt")).toBe(path.normalize("/custom/home/file.txt")); + }); + + test("4. Dollar sign env vars: $VAR/path", () => { + expect(expandPath("$TEST_HOME/file.txt")).toBe(path.normalize("/custom/home/file.txt")); + }); + + test("5. Windows %VAR% expansion: %VAR%\\path (simulated)", () => { + // We can't easily mock process.platform if it's not win32, + // but the function check process.platform === 'win32' + if (process.platform === 'win32') { + expect(expandPath("%TEST_HOME%\\file.txt")).toBe(path.normalize("/custom/home/file.txt")); + } else { + // Should remain unchanged on non-windows (but separators normalized) + expect(expandPath("%TEST_HOME%\\file.txt")).toBe(path.normalize("%TEST_HOME%/file.txt")); + } + }); + + test("6. Mixed path separators normalization", () => { + const result = expandPath("path/to\\some/file"); + expect(result).toBe(path.normalize("path/to/some/file")); + }); + + test("7. Nested env vars: ${HOME}/${VAR}/path", () => { + expect(expandPath("${TEST_HOME}/${TEST_VAR}/file.txt")).toBe(path.normalize("/custom/home/foo/file.txt")); + }); + + test("8. Empty/null path handling", () => { + expect(expandPath("")).toBe("."); + }); + + test("9. Already absolute paths stay unchanged", () => { + const absPath = "/absolute/path"; + expect(expandPath(absPath)).toBe(path.normalize(absPath)); + }); + + test("10. Undefined env vars -> empty string", () => { + expect(expandPath("${UNDEFINED_VAR}/file.txt")).toBe(path.normalize("/file.txt")); + expect(expandPath("$UNDEFINED_VAR/file.txt")).toBe(path.normalize("/file.txt")); + }); + + test("11. Windows drive letters stay intact", () => { + // Windows drive letter paths should be preserved + const result = expandPath("C:\\Users\\test\\file.txt"); + expect(result).toContain("Users"); + expect(result).toContain("test"); + }); + + test("12. Windows UNC paths handled", () => { + // UNC paths start with \\ + const uncPath = "\\\\server\\share\\folder"; + const result = expandPath(uncPath); + // Should normalize but preserve the structure + expect(result).toContain("server"); + expect(result).toContain("share"); + }); + + test("13. Null-like input throws TypeError", () => { + // Function requires string input - documents current behavior + // @ts-ignore - testing runtime edge case + expect(() => expandPath(undefined as unknown as string)).toThrow(TypeError); + // @ts-ignore - testing runtime edge case + expect(() => expandPath(null as unknown as string)).toThrow(TypeError); + }); + + test("14. Path with spaces preserved", () => { + const pathWithSpaces = "~/My Documents/file.txt"; + const result = expandPath(pathWithSpaces); + expect(result).toContain("My Documents"); + }); + + test("15. Multiple consecutive slashes normalized", () => { + const result = expandPath("path//to///file.txt"); + expect(result).toBe(path.normalize("path/to/file.txt")); + }); +}); diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx index 7d398ac3..bc93dd5c 100644 --- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -5,7 +5,8 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { User, Plus } from 'lucide-react'; +import { Switch } from '@/components/ui/switch'; +import { User, Plus, Globe } from 'lucide-react'; import { AccountItem } from './account-item'; import type { OAuthAccount } from '@/lib/api-client'; @@ -16,6 +17,11 @@ interface AccountsSectionProps { onRemoveAccount: (accountId: string) => void; isRemovingAccount?: boolean; privacyMode?: boolean; + /** Kiro-specific: show "use normal browser" toggle */ + isKiro?: boolean; + kiroNoIncognito?: boolean; + onKiroNoIncognitoChange?: (enabled: boolean) => void; + kiroSettingsLoading?: boolean; } export function AccountsSection({ @@ -25,6 +31,10 @@ export function AccountsSection({ onRemoveAccount, isRemovingAccount, privacyMode, + isKiro, + kiroNoIncognito, + onKiroNoIncognitoChange, + kiroSettingsLoading, }: AccountsSectionProps) { return (
@@ -64,6 +74,24 @@ export function AccountsSection({

Add an account to get started

)} + + {/* Kiro-specific: Incognito browser setting - users complain "it keeps opening incognito" */} + {isKiro && onKiroNoIncognitoChange && ( +
+
+
+ + Use incognito +
+ onKiroNoIncognitoChange(!v)} + disabled={kiroSettingsLoading} + className="scale-90" + /> +
+
+ )} ); } diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index ab278d5e..ac17648e 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -157,6 +157,7 @@ export function ProviderEditor({ className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden" > { + if (!isKiro) return; + try { + setKiroSettingsLoading(true); + const unifiedConfig = await api.config.get(); + const cliproxyConfig = unifiedConfig.cliproxy as { kiro_no_incognito?: boolean } | undefined; + setKiroNoIncognito(cliproxyConfig?.kiro_no_incognito ?? true); + } catch { + setKiroNoIncognito(true); + } finally { + setKiroSettingsLoading(false); + } + }, [isKiro]); + + // Save Kiro no-incognito setting + const saveKiroNoIncognito = useCallback(async (enabled: boolean) => { + setKiroNoIncognito(enabled); // Optimistic update + setKiroSaving(true); + try { + const unifiedConfig = await api.config.get(); + const existingCliproxy = (unifiedConfig.cliproxy ?? {}) as Record; + await api.config.update({ + ...unifiedConfig, + cliproxy: { + ...existingCliproxy, + kiro_no_incognito: enabled, + }, + }); + } catch { + setKiroNoIncognito(!enabled); // Revert on error + } finally { + setKiroSaving(false); + } + }, []); + + // Load Kiro settings on mount + useEffect(() => { + fetchKiroSettings(); + }, [fetchKiroSettings]); + return (
@@ -82,6 +133,10 @@ export function ModelConfigTab({ onRemoveAccount={onRemoveAccount} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} + isKiro={isKiro} + kiroNoIncognito={kiroNoIncognito} + onKiroNoIncognitoChange={saveKiroNoIncognito} + kiroSettingsLoading={kiroSettingsLoading || kiroSaving} />
diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 30f46e30..37f92035 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -66,7 +66,12 @@ interface ProfileCreateDialogProps { // Common URL mistakes to warn about const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions']; -export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCreateDialogProps) { +export function ProfileCreateDialog({ + open, + onOpenChange, + onSuccess, + initialMode = 'openrouter', +}: ProfileCreateDialogProps) { const createMutation = useCreateProfile(); const [activeTab, setActiveTab] = useState('basic'); const [urlWarning, setUrlWarning] = useState(null); @@ -123,18 +128,29 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr setActiveTab('basic'); setUrlWarning(null); setShowApiKey(false); - setSelectedPreset('openrouter'); setModelSearch(''); - // Pre-fill with OpenRouter preset - const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter'); - if (openrouterPreset) { + + // Set initial preset based on initialMode + if (initialMode === 'normal') { + // Custom mode - clear form + setSelectedPreset('custom'); setTimeout(() => { - setValue('name', openrouterPreset.defaultProfileName); - setValue('baseUrl', openrouterPreset.baseUrl); + setValue('name', ''); + setValue('baseUrl', ''); }, 0); + } else { + // OpenRouter mode (default) + setSelectedPreset('openrouter'); + const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter'); + if (openrouterPreset) { + setTimeout(() => { + setValue('name', openrouterPreset.defaultProfileName); + setValue('baseUrl', openrouterPreset.baseUrl); + }, 0); + } } } - }, [open, reset, setValue]); + }, [open, reset, setValue, initialMode]); // Handle preset selection const handlePresetSelect = (presetId: string) => { @@ -244,14 +260,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr type="button" onClick={() => handlePresetSelect('custom')} className={cn( - 'flex items-center gap-1.5 px-3 py-1.5 rounded-md border transition-all text-sm', + 'flex items-center gap-2 px-4 py-2 rounded-md border-2 transition-all text-sm font-medium', selectedPreset === 'custom' || getPresetsByCategory('alternative').some((p) => p.id === selectedPreset) - ? 'border-primary bg-primary/5 font-medium' - : 'border-muted hover:border-muted-foreground/30' + ? 'border-primary bg-primary/10 text-primary dark:bg-primary/20' + : 'border-dashed border-muted-foreground/40 hover:border-primary/50 hover:bg-muted/50 text-muted-foreground hover:text-foreground' )} > - + Custom @@ -260,8 +276,8 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr {/* Show alternative presets when Custom is selected or an alternative is selected */} {(selectedPreset === 'custom' || getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)) && ( -
-