From c811fdfc7914cc3bde3811ea04281055ebb3e273 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 03:52:27 -0500 Subject: [PATCH 01/18] fix(qwen): inherit stdin for Device Code flows to enable interactive prompts Device Code flows (Qwen, GHCP) may require interactive terminal input before generating the device code. For example, Qwen prompts for email. Previously, all auth processes used piped stdin which blocked user input. Now Device Code flows use 'inherit' for stdin, allowing users to respond to prompts directly in the terminal. Authorization Code flows (Gemini, Codex) still use piped stdin for programmatic project selection. Closes #188 --- src/cliproxy/auth/oauth-process.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 51848279..60396c07 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -112,8 +112,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); } @@ -258,8 +258,13 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise((resolve) => { + // Device Code flows (Qwen, GHCP) may need interactive stdin for email/prompts + // Authorization Code flows need piped stdin for project selection + const isDeviceCodeFlow = callbackPort === null; + const stdinMode = isDeviceCodeFlow ? 'inherit' : 'pipe'; + const authProcess = spawn(binaryPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], + stdio: [stdinMode, 'pipe', 'pipe'], env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir }, }); @@ -291,7 +296,6 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { if (isDeviceCodeFlow) { // Device Code Flow: show polling message From a1e93eb94081bc6b32ad7ed21a7b96d0601b2462 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Dec 2025 09:12:01 +0000 Subject: [PATCH 02/18] chore(release): 7.6.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 03b9e8b5..1c7d7905 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.6.0", + "version": "7.6.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 13e4baca228313462b3e0e83d0b97594654a989b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 05:02:33 -0500 Subject: [PATCH 03/18] fix(kiro): add --no-incognito option for normal browser auth Kiro OAuth always opens in incognito browser mode, which doesn't save login credentials. Users need to re-enter email each authentication. Changes: - Add noIncognito option to OAuthOptions interface - Add kiro_no_incognito config option to CLIProxyConfig - Pass --no-incognito flag to CLIProxyAPI binary when enabled - Support both CLI flag (--no-incognito) and config.yaml setting Usage: - CLI: ccs kiro --auth --no-incognito - Config: cliproxy.kiro_no_incognito: true in config.yaml --- src/cliproxy/auth/auth-types.ts | 2 ++ src/cliproxy/auth/oauth-handler.ts | 6 +++++- src/cliproxy/cliproxy-executor.ts | 7 +++++++ src/config/unified-config-types.ts | 2 ++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 4bb06256..4bcc476c 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -171,4 +171,6 @@ export interface OAuthOptions { nickname?: string; /** If true, triggered from Web UI (enables project selection prompt) */ fromUI?: boolean; + /** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */ + noIncognito?: boolean; } diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index f2bb51c2..2d73e7d0 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -126,7 +126,7 @@ export async function triggerOAuth( options: OAuthOptions = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false, add = false, nickname, fromUI = false } = options; + const { verbose = false, add = false, nickname, fromUI = false, noIncognito = false } = 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) { diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 6152be27..6f5d20a6 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -250,6 +250,11 @@ export async function execClaudeWithCLIProxy( const forceConfig = argsWithoutProxy.includes('--config'); const addAccount = argsWithoutProxy.includes('--add'); const showAccounts = argsWithoutProxy.includes('--accounts'); + // Kiro-specific: --no-incognito to use normal browser (saves login credentials) + const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito'); + // Also check config.yaml for kiro_no_incognito setting + const kiroNoIncognitoConfig = provider === 'kiro' && unifiedConfig.cliproxy?.kiro_no_incognito; + const noIncognito = noIncognitoFlag || kiroNoIncognitoConfig; // Parse --use flag let useAccount: string | undefined; @@ -364,6 +369,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 +597,7 @@ export async function execClaudeWithCLIProxy( '--accounts', '--use', '--nickname', + '--no-incognito', // Proxy flags are handled by resolveProxyConfig, but list for documentation ...PROXY_CLI_FLAGS, ]; diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 333ef307..53760348 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -89,6 +89,8 @@ export interface CLIProxyConfig { variants: Record; /** Logging configuration (disabled by default) */ logging?: CLIProxyLoggingConfig; + /** Kiro: disable incognito browser mode (use normal browser to save credentials) */ + kiro_no_incognito?: boolean; } /** From 083e67426c382ce534bed4830bedbede94cfdca7 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 05:26:02 -0500 Subject: [PATCH 04/18] feat(kiro): add UI toggle and auth hint for --no-incognito option - Add "Kiro: Use normal browser" toggle in config dashboard (Proxy section) - Add --no-incognito documentation to ccs --help output - Show hint after successful Kiro auth about saving AWS credentials --- src/cliproxy/auth/oauth-handler.ts | 12 +++- src/commands/help-command.ts | 1 + .../pages/settings/sections/proxy/index.tsx | 70 ++++++++++++++++++- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 2d73e7d0..877c9e8f 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -202,7 +202,7 @@ export async function triggerOAuth( } // Execute OAuth process - return executeOAuthProcess({ + const account = await executeOAuthProcess({ provider, binaryPath, args, @@ -214,6 +214,16 @@ export async function triggerOAuth( isCLI, nickname, }); + + // Show hint for Kiro users about --no-incognito option (first-time auth only) + if (account && provider === 'kiro' && !noIncognito) { + console.log(''); + console.log(info('Tip: To save your AWS login credentials for future sessions:')); + console.log(' Use: ccs kiro --no-incognito'); + console.log(' Or enable "Kiro: Use normal browser" in: ccs config'); + } + + return account; } /** diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 224230ae..984b1ed3 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -173,6 +173,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], + ['ccs kiro --no-incognito', 'Use normal browser (saves AWS login)'], ['ccs codex "explain code"', 'Use with prompt'], ] ); diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 14a8b666..18b0737e 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -3,7 +3,7 @@ * Settings section for CLIProxyAPI configuration (local/remote) */ -import { useEffect } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -12,6 +12,7 @@ import { RefreshCw, CheckCircle2, AlertCircle, Laptop, Cloud } from 'lucide-reac import { useProxyConfig, useRawConfig } from '../../hooks'; import { LocalProxyCard } from './local-proxy-card'; import { RemoteProxyCard } from './remote-proxy-card'; +import { api } from '@/lib/api-client'; export default function ProxySection() { const { @@ -37,11 +38,54 @@ export default function ProxySection() { const { fetchRawConfig } = useRawConfig(); + // Kiro provider settings state + const [kiroNoIncognito, setKiroNoIncognito] = useState(false); + const [kiroSettingsLoading, setKiroSettingsLoading] = useState(true); + const [kiroSaving, setKiroSaving] = useState(false); + + // Fetch Kiro settings from unified config + const fetchKiroSettings = useCallback(async () => { + try { + setKiroSettingsLoading(true); + const unifiedConfig = await api.config.get(); + const cliproxyConfig = unifiedConfig.cliproxy as { kiro_no_incognito?: boolean } | undefined; + setKiroNoIncognito(cliproxyConfig?.kiro_no_incognito ?? false); + } catch { + // Config may not exist yet, use default + setKiroNoIncognito(false); + } finally { + setKiroSettingsLoading(false); + } + }, []); + + // Save Kiro no-incognito setting + const saveKiroNoIncognito = useCallback(async (enabled: boolean) => { + setKiroNoIncognito(enabled); // Optimistic update + setKiroSaving(true); + try { + const unifiedConfig = await api.config.get(); + const existingCliproxy = (unifiedConfig.cliproxy ?? {}) as Record; + await api.config.update({ + ...unifiedConfig, + cliproxy: { + ...existingCliproxy, + kiro_no_incognito: enabled, + }, + }); + } catch { + // Revert on error + setKiroNoIncognito(!enabled); + } finally { + setKiroSaving(false); + } + }, []); + // Load data on mount useEffect(() => { fetchConfig(); fetchRawConfig(); - }, [fetchConfig, fetchRawConfig]); + fetchKiroSettings(); + }, [fetchConfig, fetchRawConfig, fetchKiroSettings]); if (loading || !config) { return ( @@ -265,6 +309,27 @@ export default function ProxySection() { onSaveConfig={saveConfig} /> )} + + {/* Provider Settings */} +
+

Provider Settings

+
+ {/* Kiro: Use normal browser */} +
+
+

Kiro: Use normal browser

+

+ Save AWS login credentials (disable incognito mode) +

+
+ +
+
+
@@ -276,6 +341,7 @@ export default function ProxySection() { onClick={() => { fetchConfig(); fetchRawConfig(); + fetchKiroSettings(); }} disabled={loading || saving} className="w-full" From df0c94781e5f198f867723e1b5bccf17d6c4b250 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 06:17:17 -0500 Subject: [PATCH 05/18] feat(kiro): improve auth UX with normal browser default and URL display - Change default to noIncognito=true (normal browser) for reliability - Always print OAuth URL to terminal for VS Code popup detection - Move incognito toggle from Proxy settings to Kiro provider page - Add --incognito flag to opt into incognito mode (was --no-incognito) - Update help text to reflect new defaults --- src/cliproxy/auth/oauth-handler.ts | 2 +- src/cliproxy/auth/oauth-process.ts | 4 +- src/cliproxy/cliproxy-executor.ts | 14 ++-- src/commands/help-command.ts | 2 +- src/web-server/routes/cliproxy-auth-routes.ts | 12 +++- .../provider-editor/accounts-section.tsx | 30 +++++++- .../cliproxy/provider-editor/index.tsx | 1 + .../provider-editor/model-config-tab.tsx | 57 ++++++++++++++- .../pages/settings/sections/proxy/index.tsx | 70 +------------------ 9 files changed, 113 insertions(+), 79 deletions(-) diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 877c9e8f..caba977c 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -126,7 +126,7 @@ export async function triggerOAuth( options: OAuthOptions = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false, add = false, nickname, fromUI = false, noIncognito = 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(); diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 60396c07..6a16ebff 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -172,8 +172,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(''); diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 6f5d20a6..758bf39b 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -250,11 +250,16 @@ export async function execClaudeWithCLIProxy( const forceConfig = argsWithoutProxy.includes('--config'); const addAccount = argsWithoutProxy.includes('--add'); const showAccounts = argsWithoutProxy.includes('--accounts'); - // Kiro-specific: --no-incognito to use normal browser (saves login credentials) + // 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'); - // Also check config.yaml for kiro_no_incognito setting - const kiroNoIncognitoConfig = provider === 'kiro' && unifiedConfig.cliproxy?.kiro_no_incognito; - const noIncognito = noIncognitoFlag || kiroNoIncognitoConfig; + // Config setting (defaults to true = normal browser) + const kiroNoIncognitoConfig = + provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false; + // --incognito flag overrides everything to use incognito + const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig; // Parse --use flag let useAccount: string | undefined; @@ -597,6 +602,7 @@ export async function execClaudeWithCLIProxy( '--accounts', '--use', '--nickname', + '--incognito', '--no-incognito', // Proxy flags are handled by resolveProxyConfig, but list for documentation ...PROXY_CLI_FLAGS, diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 984b1ed3..82f70a86 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -173,7 +173,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], - ['ccs kiro --no-incognito', 'Use normal browser (saves AWS login)'], + ['ccs kiro --incognito', 'Use incognito browser (default: normal)'], ['ccs codex "explain code"', 'Use with prompt'], ] ); diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 1abf36d2..637cf2d3 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -23,6 +23,7 @@ import { } from '../../cliproxy/account-manager'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import type { CLIProxyProvider } from '../../cliproxy/types'; const router = Router(); @@ -268,7 +269,7 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v */ router.post('/:provider/start', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { nickname } = req.body; + const { nickname, noIncognito: noIncognitoBody } = req.body; // Validate provider if (!validProviders.includes(provider as CLIProxyProvider)) { @@ -276,6 +277,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise void; isRemovingAccount?: boolean; privacyMode?: boolean; + /** Kiro-specific: show "use normal browser" toggle */ + isKiro?: boolean; + kiroNoIncognito?: boolean; + onKiroNoIncognitoChange?: (enabled: boolean) => void; + kiroSettingsLoading?: boolean; } export function AccountsSection({ @@ -25,6 +31,10 @@ export function AccountsSection({ onRemoveAccount, isRemovingAccount, privacyMode, + isKiro, + kiroNoIncognito, + onKiroNoIncognitoChange, + kiroSettingsLoading, }: AccountsSectionProps) { return (
@@ -64,6 +74,24 @@ export function AccountsSection({

Add an account to get started

)} + + {/* Kiro-specific: Incognito browser setting - users complain "it keeps opening incognito" */} + {isKiro && onKiroNoIncognitoChange && ( +
+
+
+ + Use incognito +
+ onKiroNoIncognitoChange(!v)} + disabled={kiroSettingsLoading} + className="scale-90" + /> +
+
+ )} ); } diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index ab278d5e..ac17648e 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -157,6 +157,7 @@ export function ProviderEditor({ className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden" > { + if (!isKiro) return; + try { + setKiroSettingsLoading(true); + const unifiedConfig = await api.config.get(); + const cliproxyConfig = unifiedConfig.cliproxy as { kiro_no_incognito?: boolean } | undefined; + setKiroNoIncognito(cliproxyConfig?.kiro_no_incognito ?? true); + } catch { + setKiroNoIncognito(true); + } finally { + setKiroSettingsLoading(false); + } + }, [isKiro]); + + // Save Kiro no-incognito setting + const saveKiroNoIncognito = useCallback(async (enabled: boolean) => { + setKiroNoIncognito(enabled); // Optimistic update + setKiroSaving(true); + try { + const unifiedConfig = await api.config.get(); + const existingCliproxy = (unifiedConfig.cliproxy ?? {}) as Record; + await api.config.update({ + ...unifiedConfig, + cliproxy: { + ...existingCliproxy, + kiro_no_incognito: enabled, + }, + }); + } catch { + setKiroNoIncognito(!enabled); // Revert on error + } finally { + setKiroSaving(false); + } + }, []); + + // Load Kiro settings on mount + useEffect(() => { + fetchKiroSettings(); + }, [fetchKiroSettings]); + return (
@@ -82,6 +133,10 @@ export function ModelConfigTab({ onRemoveAccount={onRemoveAccount} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} + isKiro={isKiro} + kiroNoIncognito={kiroNoIncognito} + onKiroNoIncognitoChange={saveKiroNoIncognito} + kiroSettingsLoading={kiroSettingsLoading || kiroSaving} />
diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 18b0737e..14a8b666 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -3,7 +3,7 @@ * Settings section for CLIProxyAPI configuration (local/remote) */ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -12,7 +12,6 @@ import { RefreshCw, CheckCircle2, AlertCircle, Laptop, Cloud } from 'lucide-reac import { useProxyConfig, useRawConfig } from '../../hooks'; import { LocalProxyCard } from './local-proxy-card'; import { RemoteProxyCard } from './remote-proxy-card'; -import { api } from '@/lib/api-client'; export default function ProxySection() { const { @@ -38,54 +37,11 @@ export default function ProxySection() { const { fetchRawConfig } = useRawConfig(); - // Kiro provider settings state - const [kiroNoIncognito, setKiroNoIncognito] = useState(false); - const [kiroSettingsLoading, setKiroSettingsLoading] = useState(true); - const [kiroSaving, setKiroSaving] = useState(false); - - // Fetch Kiro settings from unified config - const fetchKiroSettings = useCallback(async () => { - try { - setKiroSettingsLoading(true); - const unifiedConfig = await api.config.get(); - const cliproxyConfig = unifiedConfig.cliproxy as { kiro_no_incognito?: boolean } | undefined; - setKiroNoIncognito(cliproxyConfig?.kiro_no_incognito ?? false); - } catch { - // Config may not exist yet, use default - setKiroNoIncognito(false); - } finally { - setKiroSettingsLoading(false); - } - }, []); - - // Save Kiro no-incognito setting - const saveKiroNoIncognito = useCallback(async (enabled: boolean) => { - setKiroNoIncognito(enabled); // Optimistic update - setKiroSaving(true); - try { - const unifiedConfig = await api.config.get(); - const existingCliproxy = (unifiedConfig.cliproxy ?? {}) as Record; - await api.config.update({ - ...unifiedConfig, - cliproxy: { - ...existingCliproxy, - kiro_no_incognito: enabled, - }, - }); - } catch { - // Revert on error - setKiroNoIncognito(!enabled); - } finally { - setKiroSaving(false); - } - }, []); - // Load data on mount useEffect(() => { fetchConfig(); fetchRawConfig(); - fetchKiroSettings(); - }, [fetchConfig, fetchRawConfig, fetchKiroSettings]); + }, [fetchConfig, fetchRawConfig]); if (loading || !config) { return ( @@ -309,27 +265,6 @@ export default function ProxySection() { onSaveConfig={saveConfig} /> )} - - {/* Provider Settings */} -
-

Provider Settings

-
- {/* Kiro: Use normal browser */} -
-
-

Kiro: Use normal browser

-

- Save AWS login credentials (disable incognito mode) -

-
- -
-
-
@@ -341,7 +276,6 @@ export default function ProxySection() { onClick={() => { fetchConfig(); fetchRawConfig(); - fetchKiroSettings(); }} disabled={loading || saving} className="w-full" From 50653d1054f89f0eaff24a6d8f471266269383b6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 17:07:56 -0500 Subject: [PATCH 06/18] fix(ui): add gemini-3-flash-preview to model dropdowns Added gemini-3-flash-preview model to agy and gemini provider catalogs in the UI. This enables users to select the fast Gemini 3 Flash model from dropdown menus. Closes #194 --- ui/src/lib/model-catalogs.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index a4924c47..13b25fc0 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -53,7 +53,18 @@ export const MODEL_CATALOGS: Record = { 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 = { 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', }, }, { From fdc5439c7808079c9ee65b79b7e3cf3a2258626b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Dec 2025 22:53:56 +0000 Subject: [PATCH 07/18] chore(release): 7.6.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1c7d7905..0c6d0c3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.6.0-dev.1", + "version": "7.6.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From cc2d62db38977fd5a0597388c2882e3600e5e179 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 18:09:09 -0500 Subject: [PATCH 08/18] fix(profiles): prevent GLM auth regression from first-time install detection - Check legacy config.json/profiles.json in isFirstTimeInstall() - Use expandPath() for cross-platform path handling in profile-detector - Add pre-flight API key validation for better error messages - Enhance 401 error handling with Z.AI refresh guidance Fixes #195 --- src/auth/profile-detector.ts | 7 +- src/ccs.ts | 27 ++++++ src/commands/setup-command.ts | 87 +++++++++++++----- src/config/migration-manager.ts | 158 +++++++++++++++++++++++++++----- src/glmt/glmt-proxy.ts | 9 +- src/utils/api-key-validator.ts | 115 +++++++++++++++++++++++ 6 files changed, 353 insertions(+), 50 deletions(-) create mode 100644 src/utils/api-key-validator.ts diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 014d5407..328e7406 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -12,7 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { findSimilarStrings } from '../utils/helpers'; +import { findSimilarStrings, expandPath } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types'; import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; @@ -62,10 +62,11 @@ export interface ProfileNotFoundError extends Error { */ /** * Load env vars from a settings file (*.settings.json). - * Expands ~ to home directory. Returns empty object on error. + * Uses expandPath() for consistent cross-platform path handling. + * Returns empty object on error. */ function loadSettingsFromFile(settingsPath: string): Record { - const expandedPath = settingsPath.replace(/^~/, os.homedir()); + const expandedPath = expandPath(settingsPath); try { if (!fs.existsSync(expandedPath)) return {}; const content = fs.readFileSync(expandedPath, 'utf8'); diff --git a/src/ccs.ts b/src/ccs.ts index ced3fc28..9999428b 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath, loadSettings } from './utils/config-manager'; +import { validateGlmKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; import { @@ -508,6 +509,32 @@ async function main(): Promise { // Display WebSearch status (single line, equilibrium UX) displayWebSearchStatus(); + // Pre-flight validation for GLM/GLMT profiles + if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') { + const preflightSettingsPath = getSettingsPath(profileInfo.name); + const preflightSettings = loadSettings(preflightSettingsPath); + const apiKey = preflightSettings.env?.['ANTHROPIC_AUTH_TOKEN']; + + if (apiKey) { + const validation = await validateGlmKey( + apiKey, + preflightSettings.env?.['ANTHROPIC_BASE_URL'] + ); + + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')); + process.exit(1); + } + } + } + // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { // GLMT FLOW: Settings-based with embedded proxy for thinking support diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index dc0c5801..5b7acf5a 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -13,6 +13,8 @@ */ import * as readline from 'readline'; +import * as fs from 'fs'; +import * as path from 'path'; import { initUI, header, ok, info, warn } from '../utils/ui'; import { loadOrCreateUnifiedConfig, @@ -21,6 +23,7 @@ import { hasUnifiedConfig, } from '../config/unified-config-loader'; import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; +import { getCcsDir } from '../utils/config-manager'; /** Custom error for user cancellation (Ctrl+C) */ class UserCancelledError extends Error { @@ -112,39 +115,75 @@ async function selectOption( /** * Check if this is a first-time install (config exists but is empty/unconfigured) * Returns true if user should be prompted to run setup wizard + * + * IMPORTANT: Also checks legacy config.json for existing profiles to avoid + * treating users with existing GLM/Kimi setups as "first-time installs" + * (Fix for issue #195 - GLM auth persistence regression) */ export function isFirstTimeInstall(): boolean { - // No config at all → definitely first time - if (!hasUnifiedConfig()) { - return true; - } + // Check unified config first (config.yaml) + if (hasUnifiedConfig()) { + const loaded = loadUnifiedConfig(); - // Try loading config directly to detect corruption - const loaded = loadUnifiedConfig(); - if (loaded === null) { // Config exists but is corrupted/invalid - don't treat as first-time - // User should fix or delete the file, or use --force - console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted')); - console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose')); - return false; + if (loaded === null) { + console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted')); + console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose')); + return false; + } + + // Check for any meaningful configuration in unified config + const hasProfiles = Object.keys(loaded.profiles || {}).length > 0; + const hasAccounts = Object.keys(loaded.accounts || {}).length > 0; + const hasVariants = Object.keys(loaded.cliproxy?.variants || {}).length > 0; + const hasOAuthAccounts = Object.keys(loaded.cliproxy?.oauth_accounts || {}).length > 0; + const hasRemoteProxy = + loaded.cliproxy_server?.remote?.enabled && loaded.cliproxy_server?.remote?.host; + + // If any of these exist in unified config, user has configured something + if (hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy) { + return false; + } } - // Config exists and is valid - check if it's meaningfully configured - const config = loaded; + // Also check legacy config.json for existing profiles + // This prevents treating users with GLM/Kimi in config.json as "first-time installs" + const ccsDir = getCcsDir(); + const legacyConfigPath = path.join(ccsDir, 'config.json'); - // Check for any meaningful configuration - const hasProfiles = Object.keys(config.profiles || {}).length > 0; - const hasAccounts = Object.keys(config.accounts || {}).length > 0; - const hasVariants = Object.keys(config.cliproxy?.variants || {}).length > 0; - const hasOAuthAccounts = Object.keys(config.cliproxy?.oauth_accounts || {}).length > 0; - const hasRemoteProxy = - config.cliproxy_server?.remote?.enabled && config.cliproxy_server?.remote?.host; + if (fs.existsSync(legacyConfigPath)) { + try { + const content = fs.readFileSync(legacyConfigPath, 'utf8'); + const legacyConfig = JSON.parse(content) as { profiles?: Record }; - // If any of these exist, user has configured something - const isConfigured = - hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy; + if (legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0) { + // Has legacy profiles - NOT first time + return false; + } + } catch { + // Legacy config exists but is invalid - ignore and continue + } + } - return !isConfigured; + // Also check profiles.json for existing accounts + const legacyProfilesPath = path.join(ccsDir, 'profiles.json'); + + if (fs.existsSync(legacyProfilesPath)) { + try { + const content = fs.readFileSync(legacyProfilesPath, 'utf8'); + const legacyProfiles = JSON.parse(content) as { profiles?: Record }; + + if (legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0) { + // Has legacy accounts - NOT first time + return false; + } + } catch { + // Legacy profiles exists but is invalid - ignore and continue + } + } + + // No meaningful configuration found anywhere + return true; } /** diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 142c57e6..e45245d7 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -17,7 +17,7 @@ import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; 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 +44,47 @@ export function needsMigration(): boolean { return hasOldConfig && !hasNewConfig; } +/** + * 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) + */ +export function needsProfileMigration(): boolean { + const ccsDir = getCcsDir(); + const configJsonPath = path.join(ccsDir, 'config.json'); + + // No legacy config → nothing to migrate + if (!fs.existsSync(configJsonPath)) { + return false; + } + + const legacyConfig = readJsonSafe(configJsonPath); + const unifiedConfig = loadUnifiedConfig(); + + // No legacy profiles → nothing to migrate + if (!legacyConfig?.profiles || typeof legacyConfig.profiles !== 'object') { + return false; + } + + // No unified config → needs full migration (handled by needsMigration) + if (!unifiedConfig) { + return false; + } + + // Check if any legacy profile is missing from unified config + const legacyProfiles = legacyConfig.profiles as Record; + for (const profileName of Object.keys(legacyProfiles)) { + if (!unifiedConfig.profiles[profileName]) { + return true; // Found unmigrated profile + } + } + + return false; +} + /** * Get list of backup directories. */ @@ -326,6 +367,9 @@ async function performBackup(srcDir: string, backupDir: string): Promise { * Auto-migrate on first run after update. * Silent if already migrated or no config exists. * Shows friendly message with backup location on success. + * + * Also handles inline profile migration when config.yaml exists but is missing + * profiles from config.json (Fix for issue #195). */ export async function autoMigrate(): Promise { // Skip in test environment @@ -333,30 +377,100 @@ export async function autoMigrate(): Promise { return; } - // Skip if no migration needed - if (!needsMigration()) { + // Check if full migration is needed (no config.yaml exists) + if (needsMigration()) { + const result = await migrate(false); + + if (result.success) { + console.log(''); + console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS')); + console.log(` Backup: ${result.backupPath}`); + console.log(` Items: ${result.migratedFiles.length} migrated`); + if (result.warnings.length > 0) { + for (const warning of result.warnings) { + console.log(warn(warning)); + } + } + console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); + console.log(''); + } else { + console.log(''); + console.log(infoBox('Migration failed - using legacy config', 'WARNING')); + console.log(` Error: ${result.error}`); + console.log(' Retry: ccs migrate'); + console.log(''); + } return; } - const result = await migrate(false); - - if (result.success) { - console.log(''); - console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS')); - console.log(` Backup: ${result.backupPath}`); - console.log(` Items: ${result.migratedFiles.length} migrated`); - if (result.warnings.length > 0) { - for (const warning of result.warnings) { - console.log(warn(warning)); - } + // Check if inline profile migration is needed (config.yaml exists but missing profiles) + if (needsProfileMigration()) { + const result = await migrateProfilesToUnified(); + 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(', ')}`); + 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. + */ +async function migrateProfilesToUnified(): Promise { + const ccsDir = getCcsDir(); + const migratedFiles: string[] = []; + const warnings: string[] = []; + + try { + const oldConfig = readJsonSafe(path.join(ccsDir, 'config.json')); + const 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)) { + // Skip if already in unified config + if (unifiedConfig.profiles[name]) { + continue; + } + + const pathStr = settingsPath as string; + const expandedPath = expandPath(pathStr); + + // Verify settings file exists + if (!fs.existsSync(expandedPath)) { + warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`); + continue; + } + + // Store reference to settings file + unifiedConfig.profiles[name] = { + type: 'api', + settings: pathStr, + }; + migratedFiles.push(name); + modified = true; + } + + // Save if modified + if (modified) { + saveUnifiedConfig(unifiedConfig); + } + + return { success: true, migratedFiles, warnings }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + migratedFiles, + warnings, + }; } } diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts index 9edb832d..d1170f6e 100644 --- a/src/glmt/glmt-proxy.ts +++ b/src/glmt/glmt-proxy.ts @@ -587,7 +587,14 @@ export class GlmtProxy { statusCode: 401, type: 'authentication_error', message: - 'Authorization token missing or invalid. Please check your ANTHROPIC_AUTH_TOKEN environment variable in ~/.ccs/config.json or settings.json.', + 'API key rejected by Z.AI. This can happen when:\n' + + ' 1. Key expired on Z.AI server (most common after idle periods)\n' + + ' 2. Key was revoked or regenerated\n' + + ' 3. Key is missing or malformed\n\n' + + 'To fix:\n' + + ' 1. Go to Z.AI dashboard and regenerate your API key\n' + + ' 2. Update ~/.ccs/glm.settings.json with the new key\n' + + ' 3. Or run: ccs config -> API Profiles -> GLM -> Update key', }; } diff --git a/src/utils/api-key-validator.ts b/src/utils/api-key-validator.ts new file mode 100644 index 00000000..f49060f2 --- /dev/null +++ b/src/utils/api-key-validator.ts @@ -0,0 +1,115 @@ +/** + * API Key Pre-flight Validator + * + * Quick validation of API keys before Claude CLI launch. + * Catches expired keys early with actionable error messages. + */ + +import * as https from 'https'; +import { URL } from 'url'; + +export interface ValidationResult { + valid: boolean; + error?: string; + suggestion?: string; +} + +/** Default placeholders that indicate unconfigured keys */ +const DEFAULT_PLACEHOLDERS = [ + 'YOUR_GLM_API_KEY_HERE', + 'YOUR_KIMI_API_KEY_HERE', + 'YOUR_API_KEY_HERE', + 'your-api-key-here', + 'PLACEHOLDER', + '', +]; + +/** + * Validate GLM API key with quick health check + * + * @param apiKey - The ANTHROPIC_AUTH_TOKEN value + * @param baseUrl - Optional base URL (defaults to Z.AI) + * @param timeoutMs - Timeout in milliseconds (default 2000) + */ +export async function validateGlmKey( + apiKey: string, + baseUrl?: string, + timeoutMs = 2000 +): Promise { + // Skip if disabled + if (process.env.CCS_SKIP_PREFLIGHT === '1') { + return { valid: true }; + } + + // Basic format check - detect placeholders + if (!apiKey || DEFAULT_PLACEHOLDERS.includes(apiKey.toUpperCase())) { + return { + valid: false, + error: 'API key not configured', + suggestion: + 'Set ANTHROPIC_AUTH_TOKEN in ~/.ccs/glm.settings.json\n' + + 'Or run: ccs config -> API Profiles -> GLM', + }; + } + + // Determine validation endpoint + // Z.AI uses /api/anthropic path, we can test with a minimal request + const targetBase = baseUrl || 'https://api.z.ai'; + let url: URL; + try { + url = new URL('/api/anthropic/v1/models', targetBase); + } catch { + // Invalid URL - fail-open + return { valid: true }; + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + // Fail-open on timeout - let Claude CLI handle it + resolve({ valid: true }); + }, timeoutMs); + + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'User-Agent': 'CCS-Preflight/1.0', + }, + }; + + const req = https.request(options, (res) => { + clearTimeout(timeout); + + 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 + resolve({ valid: true }); + } + + // Consume response body to free resources + res.resume(); + }); + + req.on('error', () => { + clearTimeout(timeout); + // Network error - fail-open + resolve({ valid: true }); + }); + + req.end(); + }); +} From adb6222bc671c3c4ade1bb019705a985de1947fa Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 18:19:47 -0500 Subject: [PATCH 09/18] refactor(paths): use expandPath() consistently for cross-platform path handling - variant-settings.ts: Use expandPath() in deleteSettingsFile() - profile-writer.ts: Use expandPath() in removeApiProfileUnified() - migration-manager.ts: Remove local expandPath(), import from helpers All path expansion now uses the central expandPath() from utils/helpers.ts which handles ~, ~\, ${VAR}, $VAR, and %VAR% on all platforms. --- src/api/services/profile-writer.ts | 6 ++++-- src/cliproxy/services/variant-settings.ts | 6 ++++-- src/config/migration-manager.ts | 8 +------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index e48ff6fa..fbc42a71 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getCcsDir, getConfigPath, loadConfig } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig, @@ -144,9 +145,10 @@ function removeApiProfileUnified(name: string): void { throw new Error(`API profile not found: ${name}`); } - // Delete the settings file if it exists + // Delete the settings file if it exists. + // Uses expandPath() for cross-platform path handling. if (profile.settings) { - const settingsPath = profile.settings.replace(/^~/, os.homedir()); + const settingsPath = expandPath(profile.settings); if (fs.existsSync(settingsPath)) { fs.unlinkSync(settingsPath); } diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index b3c4b185..47afe52f 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import * as os from 'os'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { getCcsDir } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIProxyProvider } from '../types'; @@ -124,10 +125,11 @@ export function createSettingsFileUnified( } /** - * Delete settings file if it exists + * Delete settings file if it exists. + * Uses expandPath() for cross-platform path handling. */ export function deleteSettingsFile(settingsPath: string): boolean { - const resolvedPath = settingsPath.replace(/^~/, os.homedir()); + const resolvedPath = expandPath(settingsPath); if (fs.existsSync(resolvedPath)) { fs.unlinkSync(resolvedPath); return true; diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index e45245d7..9cc4cb43 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -15,6 +15,7 @@ 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, loadUnifiedConfig } from './unified-config-loader'; @@ -311,13 +312,6 @@ function readJsonSafe(filePath: string): Record | null { } } -/** - * Expand ~ to home directory in path. - */ -function expandPath(p: string): string { - return p.replace(/^~/, process.env.HOME || ''); -} - /** * Move file if it exists. Returns true if moved, false if source didn't exist. */ From e7066b99972129114fb223c6cde40f3127599ae6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 18:32:07 -0500 Subject: [PATCH 10/18] feat(api): add Minimax, DeepSeek, Qwen provider presets Add three new Anthropic-compatible API providers as presets: - Minimax: M2.1/M2.1-lightning/M2 models with 1M context - DeepSeek: V3.2 and R1 reasoning model (128K context) - Qwen: Alibaba Cloud qwen3-coder-plus (256K context) Closes #123 --- README.md | 3 +++ src/api/services/provider-presets.ts | 33 +++++++++++++++++++++++ src/commands/api-command.ts | 7 ++++- ui/src/lib/provider-presets.ts | 39 ++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bcc539aa..a380165f 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,9 @@ The dashboard provides visual management for all account types: | **GLM** | API Key | `ccs glm` | Cost-optimized execution | | **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode | | **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure | +| **Minimax** | API Key | `ccs minimax` | M2 series, 1M context | +| **DeepSeek** | API Key | `ccs deepseek` | V3.2 and R1 reasoning | +| **Qwen** | API Key | `ccs qwen` | Alibaba Cloud, qwen3-coder | **OpenRouter Integration** (v7.0.0): CCS v7.0.0 adds OpenRouter with interactive model picker, dynamic discovery, and tier mapping (opus/sonnet/haiku). Create via `ccs api create --preset openrouter` or dashboard. diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts index fa34b0e3..85964f0e 100644 --- a/src/api/services/provider-presets.ts +++ b/src/api/services/provider-presets.ts @@ -98,6 +98,39 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab', category: 'alternative', }, + { + id: 'minimax', + name: 'Minimax', + description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)', + baseUrl: 'https://api.minimax.io/anthropic', + defaultProfileName: 'minimax', + defaultModel: 'MiniMax-M2.1', + apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY', + apiKeyHint: 'Get your API key at platform.minimax.io', + category: 'alternative', + }, + { + id: 'deepseek', + name: 'DeepSeek', + description: 'V3.2 and R1 reasoning model (128K context)', + baseUrl: 'https://api.deepseek.com/anthropic', + defaultProfileName: 'deepseek', + defaultModel: 'deepseek-chat', + apiKeyPlaceholder: 'sk-...', + apiKeyHint: 'Get your API key at platform.deepseek.com', + category: 'alternative', + }, + { + id: 'qwen', + name: 'Qwen', + description: 'Alibaba Cloud - qwen3-coder-plus (256K context)', + baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic', + defaultProfileName: 'qwen', + defaultModel: 'qwen3-coder-plus', + apiKeyPlaceholder: 'sk-...', + apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio', + category: 'alternative', + }, ]; /** Get preset by ID */ diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 076eeb6c..06dfa95f 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -426,7 +426,7 @@ async function showHelp(): Promise { console.log(''); console.log(subheader('Options')); console.log( - ` ${color('--preset ', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry)` + ` ${color('--preset ', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry, minimax, deepseek, qwen)` ); console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); @@ -442,6 +442,11 @@ async function showHelp(): Promise { console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`); console.log(` ${color('kimi', 'command')} Kimi - Moonshot AI reasoning model`); console.log(` ${color('foundry', 'command')} Azure Foundry - Claude via Microsoft Azure`); + console.log(` ${color('minimax', 'command')} Minimax - M2 series with 1M context`); + console.log(` ${color('deepseek', 'command')} DeepSeek - V3.2 and R1 reasoning (128K)`); + console.log( + ` ${color('qwen', 'command')} Qwen - Alibaba Cloud qwen3-coder-plus (256K)` + ); console.log(''); console.log(subheader('Examples')); console.log(` ${dim('# Interactive wizard')}`); diff --git a/ui/src/lib/provider-presets.ts b/ui/src/lib/provider-presets.ts index b9b7375b..facac46d 100644 --- a/ui/src/lib/provider-presets.ts +++ b/ui/src/lib/provider-presets.ts @@ -93,6 +93,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 */ From 92a79aa78ba14aaf2b22f10eaab23f6e04220b17 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 18:33:52 -0500 Subject: [PATCH 11/18] test(auth): add comprehensive tests for GLM auth persistence fix Add 26 new unit tests covering: - isFirstTimeInstall() legacy config detection (8 tests) - loadSettingsFromFile() path expansion (5 tests) - ProfileDetector detectProfileType() (4 tests) - expandPath() cross-platform handling (10 tests) Test coverage for issue #195: - Legacy config.json/profiles.json detection - Tilde expansion to home directory - Environment variable expansion (${VAR}, $VAR, %VAR%) - Mixed path separator normalization - Corrupted config graceful handling Files: - tests/unit/commands/setup-command.test.ts - tests/unit/auth/profile-detector.test.ts - tests/unit/utils/expand-path.test.ts --- src/auth/profile-detector.ts | 2 +- src/types/index.ts | 4 +- src/types/utils.ts | 3 +- src/utils/helpers.ts | 7 +- tests/unit/auth/profile-detector.test.ts | 156 ++++++++++++++++++++++ tests/unit/commands/setup-command.test.ts | 133 ++++++++++++++++++ tests/unit/utils/expand-path.test.ts | 69 ++++++++++ 7 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 tests/unit/auth/profile-detector.test.ts create mode 100644 tests/unit/commands/setup-command.test.ts create mode 100644 tests/unit/utils/expand-path.test.ts diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 328e7406..16accd5f 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -65,7 +65,7 @@ export interface ProfileNotFoundError extends Error { * Uses expandPath() for consistent cross-platform path handling. * Returns empty object on error. */ -function loadSettingsFromFile(settingsPath: string): Record { +export function loadSettingsFromFile(settingsPath: string): Record { const expandedPath = expandPath(settingsPath); try { if (!fs.existsSync(expandedPath)) return {}; diff --git a/src/types/index.ts b/src/types/index.ts index 30fec139..648ae97f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,5 +46,5 @@ export type { } from './glmt'; // Utility types -export { ErrorCode, LogLevel } from './utils'; -export type { ColorName, TerminalInfo, Result } from './utils'; +export { LogLevel } from './utils'; +export type { ErrorCode, ColorName, TerminalInfo, Result } from './utils'; diff --git a/src/types/utils.ts b/src/types/utils.ts index a9320d53..51ccc6a7 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -3,7 +3,8 @@ */ // Re-export from error-codes for consistency -export { ERROR_CODES, ErrorCode, getErrorDocUrl, getErrorCategory } from '../utils/error-codes'; +export { ERROR_CODES, getErrorDocUrl, getErrorCategory } from '../utils/error-codes'; +export type { ErrorCode } from '../utils/error-codes'; /** * Log levels diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 5f1f9031..f281f7e6 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -14,9 +14,14 @@ export function error(message: string): never { * Path expansion (~ and env vars) */ export function expandPath(pathStr: string): string { + // Normalize separators first to handle mixed paths + pathStr = pathStr.replace(/\\/g, '/'); + // Handle tilde expansion - if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) { + if (pathStr.startsWith('~/')) { pathStr = path.join(os.homedir(), pathStr.slice(2)); + } else if (pathStr === '~') { + pathStr = os.homedir(); } // Expand environment variables (Windows and Unix) diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts new file mode 100644 index 00000000..fb29028d --- /dev/null +++ b/tests/unit/auth/profile-detector.test.ts @@ -0,0 +1,156 @@ +/** + * 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 + spyOn(fs, 'existsSync').mockReturnValue(false); + + try { + expect(() => detector.detectProfileType('unknown')).toThrow(/Profile not found/); + } finally { + isUnifiedModeSpy.mockRestore(); + } + }); + }); +}); diff --git a/tests/unit/commands/setup-command.test.ts b/tests/unit/commands/setup-command.test.ts new file mode 100644 index 00000000..e4bb37b4 --- /dev/null +++ b/tests/unit/commands/setup-command.test.ts @@ -0,0 +1,133 @@ +/** + * Unit tests for setup-command.ts - isFirstTimeInstall() function + * + * Issue #195: GLM auth regression - isFirstTimeInstall() was ignoring legacy configs + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Create temp directory for test isolation +let testDir: string; + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-')); +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +// Helper to create config files +function createConfigYaml(content: string): void { + fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8'); +} + +function createConfigJson(content: object): void { + fs.writeFileSync(path.join(testDir, 'config.json'), JSON.stringify(content, null, 2), 'utf8'); +} + +function createProfilesJson(content: object): void { + fs.writeFileSync(path.join(testDir, 'profiles.json'), JSON.stringify(content, null, 2), 'utf8'); +} + +describe('isFirstTimeInstall logic', () => { + describe('legacy config detection', () => { + it('should detect legacy config.json with profiles', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createConfigJson({ profiles: { glm: '~/.ccs/glm.settings.json' } }); + + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + const hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0; + + expect(hasLegacyProfiles).toBe(true); + }); + + it('should detect legacy profiles.json with accounts', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createProfilesJson({ profiles: { work: { path: '/some/path' } } }); + + const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8')); + const hasLegacyAccounts = legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0; + + expect(hasLegacyAccounts).toBe(true); + }); + + it('should return true when no configs exist', () => { + const hasConfigYaml = fs.existsSync(path.join(testDir, 'config.yaml')); + const hasConfigJson = fs.existsSync(path.join(testDir, 'config.json')); + const hasProfilesJson = fs.existsSync(path.join(testDir, 'profiles.json')); + + expect(hasConfigYaml).toBe(false); + expect(hasConfigJson).toBe(false); + expect(hasProfilesJson).toBe(false); + }); + + it('should detect unified config with profiles', () => { + createConfigYaml(` +version: 2 +profiles: + glm: + type: api + settings: ~/.ccs/glm.settings.json +accounts: {} +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + const hasProfiles = content.includes('glm:') && content.includes('type: api'); + + expect(hasProfiles).toBe(true); + }); + + it('should detect unified config with remote proxy', () => { + createConfigYaml(` +version: 2 +profiles: {} +accounts: {} +cliproxy_server: + remote: + enabled: true + host: my-server.example.com +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + const hasRemoteProxy = content.includes('enabled: true') && content.includes('host:'); + + expect(hasRemoteProxy).toBe(true); + }); + }); + + describe('empty config handling', () => { + it('should treat empty configs as first-time', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createConfigJson({ profiles: {} }); + createProfilesJson({ profiles: {} }); + + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8')); + + const hasLegacyProfiles = Object.keys(legacyConfig.profiles || {}).length > 0; + const hasLegacyAccounts = Object.keys(legacyProfiles.profiles || {}).length > 0; + + expect(hasLegacyProfiles).toBe(false); + expect(hasLegacyAccounts).toBe(false); + }); + }); + + describe('corrupted config handling', () => { + it('should handle corrupted config.json gracefully', () => { + fs.writeFileSync(path.join(testDir, 'config.json'), 'not valid json{{{', 'utf8'); + + let hasLegacyProfiles = false; + try { + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0; + } catch { + hasLegacyProfiles = false; + } + + expect(hasLegacyProfiles).toBe(false); + }); + }); +}); diff --git a/tests/unit/utils/expand-path.test.ts b/tests/unit/utils/expand-path.test.ts new file mode 100644 index 00000000..9764dca1 --- /dev/null +++ b/tests/unit/utils/expand-path.test.ts @@ -0,0 +1,69 @@ +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")); + }); +}); From db3662b47986269ba9c12385021f4aa4bd1633f6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 18:43:41 -0500 Subject: [PATCH 12/18] fix(ui): respect initialMode in profile create dialog - Fix "Create Custom API Profile" button to open Custom tab instead of OpenRouter - Improve Custom button visibility with dashed border and better contrast - Improve Quick Templates styling with better spacing and font weight - Enhance CompactPresetCard with larger icons and clearer badge styling --- .../profiles/profile-create-dialog.tsx | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 30f46e30..37f92035 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -66,7 +66,12 @@ interface ProfileCreateDialogProps { // Common URL mistakes to warn about const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions']; -export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCreateDialogProps) { +export function ProfileCreateDialog({ + open, + onOpenChange, + onSuccess, + initialMode = 'openrouter', +}: ProfileCreateDialogProps) { const createMutation = useCreateProfile(); const [activeTab, setActiveTab] = useState('basic'); const [urlWarning, setUrlWarning] = useState(null); @@ -123,18 +128,29 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr setActiveTab('basic'); setUrlWarning(null); setShowApiKey(false); - setSelectedPreset('openrouter'); setModelSearch(''); - // Pre-fill with OpenRouter preset - const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter'); - if (openrouterPreset) { + + // Set initial preset based on initialMode + if (initialMode === 'normal') { + // Custom mode - clear form + setSelectedPreset('custom'); setTimeout(() => { - setValue('name', openrouterPreset.defaultProfileName); - setValue('baseUrl', openrouterPreset.baseUrl); + setValue('name', ''); + setValue('baseUrl', ''); }, 0); + } else { + // OpenRouter mode (default) + setSelectedPreset('openrouter'); + const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter'); + if (openrouterPreset) { + setTimeout(() => { + setValue('name', openrouterPreset.defaultProfileName); + setValue('baseUrl', openrouterPreset.baseUrl); + }, 0); + } } } - }, [open, reset, setValue]); + }, [open, reset, setValue, initialMode]); // Handle preset selection const handlePresetSelect = (presetId: string) => { @@ -244,14 +260,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr type="button" onClick={() => handlePresetSelect('custom')} className={cn( - 'flex items-center gap-1.5 px-3 py-1.5 rounded-md border transition-all text-sm', + 'flex items-center gap-2 px-4 py-2 rounded-md border-2 transition-all text-sm font-medium', selectedPreset === 'custom' || getPresetsByCategory('alternative').some((p) => p.id === selectedPreset) - ? 'border-primary bg-primary/5 font-medium' - : 'border-muted hover:border-muted-foreground/30' + ? 'border-primary bg-primary/10 text-primary dark:bg-primary/20' + : 'border-dashed border-muted-foreground/40 hover:border-primary/50 hover:bg-muted/50 text-muted-foreground hover:text-foreground' )} > - + Custom @@ -260,8 +276,8 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr {/* Show alternative presets when Custom is selected or an alternative is selected */} {(selectedPreset === 'custom' || getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)) && ( -
-