Merge pull request #199 from kaitranntt/dev

feat: v7.6.0 release - provider presets, Kiro auth, CLIProxy key preservation
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-24 22:17:26 -05:00
commit 485da53780
34 files changed
+1578 -121

No files matched your search

+3
View File
@@ -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.
+1 -1
View File
@@ -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",
+4 -2
View File
@@ -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);
}
+33
View File
@@ -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 */
+5 -4
View File
@@ -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<string, string> {
const expandedPath = settingsPath.replace(/^~/, os.homedir());
export function loadSettingsFromFile(settingsPath: string): Record<string, string> {
const expandedPath = expandPath(settingsPath);
try {
if (!fs.existsSync(expandedPath)) return {};
const content = fs.readFileSync(expandedPath, 'utf8');
+27
View File
@@ -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<void> {
// 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
+2
View File
@@ -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;
}
+16 -2
View File
@@ -126,7 +126,7 @@ export async function triggerOAuth(
options: OAuthOptions = {}
): Promise<AccountInfo | null> {
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;
}
/**
+36 -7
View File
@@ -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<Accou
};
return new Promise<AccountInfo | null>((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<Accou
});
// Show waiting message after delay
const isDeviceCodeFlow = callbackPort === null;
setTimeout(() => {
if (isDeviceCodeFlow) {
// Device Code Flow: show polling message
@@ -324,6 +344,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
// Timeout handling
const timeoutMs = headless ? 300000 : 120000;
const timeout = setTimeout(() => {
// 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<Accou
authProcess.on('exit', (code) => {
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<Accou
authProcess.on('error', (error) => {
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);
+13
View File
@@ -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 <account> 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,
];
+78 -11
View File
@@ -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');
+1
View File
@@ -55,6 +55,7 @@ export {
generateConfig,
regenerateConfig,
configNeedsRegeneration,
parseUserApiKeys,
getClaudeEnvVars,
getEffectiveEnvVars,
getProviderSettingsPath,
+4 -2
View File
@@ -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;
+6 -1
View File
@@ -426,7 +426,7 @@ async function showHelp(): Promise<void> {
console.log('');
console.log(subheader('Options'));
console.log(
` ${color('--preset <id>', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry)`
` ${color('--preset <id>', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry, minimax, deepseek, qwen)`
);
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
@@ -442,6 +442,11 @@ async function showHelp(): Promise<void> {
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')}`);
+1
View File
@@ -173,6 +173,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs <provider> --config', 'Change model (agy, gemini)'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
['ccs codex "explain code"', 'Use with prompt'],
]
);
+63 -24
View File
@@ -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<string, string> };
// 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<string, unknown> };
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;
}
/**
+176 -35
View File
@@ -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<string, unknown> | null;
unifiedConfig: ReturnType<typeof loadUnifiedConfig>;
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<string, unknown>;
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<MigrationResult> {
}
}
// 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<MigrationResult> {
}
}
// 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<string, unknown> | 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<void> {
* 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<void> {
// Skip in test environment
@@ -333,30 +382,122 @@ export async function autoMigrate(): Promise<void> {
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<MigrationResult> {
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,
};
}
}
+2
View File
@@ -89,6 +89,8 @@ export interface CLIProxyConfig {
variants: Record<string, CLIProxyVariantConfig>;
/** Logging configuration (disabled by default) */
logging?: CLIProxyLoggingConfig;
/** Kiro: disable incognito browser mode (use normal browser to save credentials) */
kiro_no_incognito?: boolean;
}
/**
+8 -1
View File
@@ -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',
};
}
+2 -2
View File
@@ -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';
+2 -1
View File
@@ -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
+130
View File
@@ -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<ValidationResult> {
// 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();
});
}
+6 -1
View File
@@ -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)
+11 -1
View File
@@ -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<void> => {
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<voi
return;
}
// Check Kiro no-incognito setting from config (or request body)
// Default to true (use normal browser) for reliability - incognito often fails
let noIncognito = true;
if (provider === 'kiro') {
const config = loadOrCreateUnifiedConfig();
noIncognito = noIncognitoBody ?? config.cliproxy?.kiro_no_incognito ?? true;
}
try {
// Trigger OAuth flow - this opens browser and waits for completion
const account = await triggerOAuth(provider as CLIProxyProvider, {
@@ -283,6 +292,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
headless: false, // Force interactive mode
nickname: nickname || undefined,
fromUI: true, // Enable project selection prompt in UI
noIncognito, // Kiro: use normal browser if enabled
});
if (account) {
+157
View File
@@ -0,0 +1,157 @@
/**
* Unit tests for Profile Detector
*/
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector';
import * as unifiedConfigLoader from '../../../src/config/unified-config-loader';
describe('ProfileDetector', () => {
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();
}
});
});
});
@@ -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
+133
View File
@@ -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);
});
});
});
+104
View File
@@ -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"));
});
});
@@ -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 (
<div>
@@ -64,6 +74,24 @@ export function AccountsSection({
<p className="text-xs opacity-70">Add an account to get started</p>
</div>
)}
{/* Kiro-specific: Incognito browser setting - users complain "it keeps opening incognito" */}
{isKiro && onKiroNoIncognitoChange && (
<div className="mt-3 pt-3 border-t border-dashed">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="w-3.5 h-3.5" />
<span>Use incognito</span>
</div>
<Switch
checked={!kiroNoIncognito}
onCheckedChange={(v) => onKiroNoIncognitoChange(!v)}
disabled={kiroSettingsLoading}
className="scale-90"
/>
</div>
</div>
)}
</div>
);
}
@@ -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"
>
<ModelConfigTab
provider={provider}
catalog={catalog}
savedPresets={savedPresets}
currentModel={currentModel}
@@ -1,16 +1,19 @@
/**
* Model Config Tab
* Contains model config section and accounts section
* Contains model config section, accounts section, and provider-specific settings
*/
import { useCallback, useEffect, useState } from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { ModelConfigSection } from './model-config-section';
import { AccountsSection } from './accounts-section';
import { api } from '@/lib/api-client';
import type { ProviderCatalog } from '../provider-model-selector';
import type { OAuthAccount } from '@/lib/api-client';
interface ModelConfigTabProps {
provider: string;
catalog?: ProviderCatalog;
savedPresets: Array<{
name: string;
@@ -38,6 +41,7 @@ interface ModelConfigTabProps {
}
export function ModelConfigTab({
provider,
catalog,
savedPresets,
currentModel,
@@ -57,6 +61,53 @@ export function ModelConfigTab({
isRemovingAccount,
privacyMode,
}: ModelConfigTabProps) {
// Kiro-specific: no-incognito setting (defaults to true = normal browser)
const isKiro = provider === 'kiro';
const [kiroNoIncognito, setKiroNoIncognito] = useState(true);
const [kiroSettingsLoading, setKiroSettingsLoading] = useState(true);
const [kiroSaving, setKiroSaving] = useState(false);
// Fetch Kiro settings from unified config
const fetchKiroSettings = useCallback(async () => {
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<string, unknown>;
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 (
<ScrollArea className="flex-1">
<div className="p-4 space-y-6">
@@ -82,6 +133,10 @@ export function ModelConfigTab({
onRemoveAccount={onRemoveAccount}
isRemovingAccount={isRemovingAccount}
privacyMode={privacyMode}
isKiro={isKiro}
kiroNoIncognito={kiroNoIncognito}
onKiroNoIncognitoChange={saveKiroNoIncognito}
kiroSettingsLoading={kiroSettingsLoading || kiroSaving}
/>
</div>
</ScrollArea>
@@ -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<string | null>(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'
)}
>
<Settings2 className="w-3.5 h-3.5 text-muted-foreground" />
<Settings2 className="w-4 h-4" />
<span>Custom</span>
</button>
</div>
@@ -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)) && (
<div className="pt-2 border-t border-dashed">
<Label className="text-xs text-muted-foreground mb-1.5 block">
<div className="pt-3 mt-2 border-t border-dashed border-muted-foreground/30">
<Label className="text-xs font-medium text-foreground/70 mb-2 block">
Quick Templates
</Label>
<div className="flex gap-2 flex-wrap">
@@ -552,24 +568,27 @@ function CompactPresetCard({
type="button"
onClick={onClick}
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-3 py-2 rounded-md border transition-all text-sm',
isSelected
? preset.featured
? 'border-accent bg-accent/10 dark:bg-accent/20 font-medium'
: 'border-primary bg-primary/5 font-medium'
: 'border-muted hover:border-muted-foreground/30'
: 'border-primary bg-primary/10 dark:bg-primary/20 font-medium'
: 'border-muted-foreground/20 hover:border-muted-foreground/40 hover:bg-muted/50'
)}
>
{preset.icon ? (
<img src={preset.icon} alt="" className="w-3.5 h-3.5" />
<img src={preset.icon} alt="" className="w-4 h-4" />
) : (
<div className="w-3.5 h-3.5 rounded-full bg-muted flex items-center justify-center text-[8px] font-bold">
<div className="w-4 h-4 rounded-full bg-muted-foreground/20 flex items-center justify-center text-[9px] font-bold text-foreground/70">
{preset.name.charAt(0)}
</div>
)}
<span>{preset.name}</span>
<span className="font-medium">{preset.name}</span>
{preset.badge && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 ml-0.5">
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 ml-0.5 bg-muted-foreground/10 text-muted-foreground"
>
{preset.badge}
</Badge>
)}
+25 -2
View File
@@ -53,7 +53,18 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
default: 'gemini-3-pro-preview',
opus: 'gemini-3-pro-preview',
sonnet: 'gemini-3-pro-preview',
haiku: 'gemini-2.5-flash',
haiku: 'gemini-3-flash-preview',
},
},
{
id: 'gemini-3-flash-preview',
name: 'Gemini 3 Flash',
description: 'Fast Gemini model via Antigravity',
presetMapping: {
default: 'gemini-3-flash-preview',
opus: 'gemini-3-pro-preview',
sonnet: 'gemini-3-pro-preview',
haiku: 'gemini-3-flash-preview',
},
},
],
@@ -72,7 +83,19 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
default: 'gemini-3-pro-preview',
opus: 'gemini-3-pro-preview',
sonnet: 'gemini-3-pro-preview',
haiku: 'gemini-2.5-flash',
haiku: 'gemini-3-flash-preview',
},
},
{
id: 'gemini-3-flash-preview',
name: 'Gemini 3 Flash',
tier: 'paid',
description: 'Fast Gemini 3 model, requires paid Google account',
presetMapping: {
default: 'gemini-3-flash-preview',
opus: 'gemini-3-pro-preview',
sonnet: 'gemini-3-pro-preview',
haiku: 'gemini-3-flash-preview',
},
},
{
+44 -1
View File
@@ -19,6 +19,10 @@ export interface ProviderPreset {
apiKeyPlaceholder: string;
apiKeyHint?: string;
category: PresetCategory;
/** Additional env vars for thinking mode, etc. */
extraEnv?: Record<string, string>;
/** Enable always thinking mode */
alwaysThinkingEnabled?: boolean;
}
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api';
@@ -93,6 +97,45 @@ 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',
badge: '1M context',
defaultModel: 'MiniMax-M2.1',
requiresApiKey: true,
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',
badge: 'Reasoning',
defaultModel: 'deepseek-chat',
requiresApiKey: true,
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',
badge: 'Alibaba',
defaultModel: 'qwen3-coder-plus',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio',
category: 'alternative',
},
];
/** Get presets by category */
@@ -102,7 +145,7 @@ export function getPresetsByCategory(category: PresetCategory): ProviderPreset[]
/** Get preset by ID */
export function getPresetById(id: string): ProviderPreset | undefined {
return PROVIDER_PRESETS.find((p) => p.id === id);
return PROVIDER_PRESETS.find((p) => p.id.toLowerCase() === id.toLowerCase());
}
/** Check if a URL matches a known preset */