mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 04:14:57 +00:00
Merge pull request #199 from kaitranntt/dev
feat: v7.6.0 release - provider presets, Kiro auth, CLIProxy key preservation
This commit is contained in:
34 files changed
+1578
-121
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -55,6 +55,7 @@ export {
|
||||
generateConfig,
|
||||
regenerateConfig,
|
||||
configNeedsRegeneration,
|
||||
parseUserApiKeys,
|
||||
getClaudeEnvVars,
|
||||
getEffectiveEnvVars,
|
||||
getProviderSettingsPath,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}`);
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in new issue
Block a user