From 949792f41e4a5b3ba5ad26296298d199db2460f9 Mon Sep 17 00:00:00 2001 From: VC Date: Fri, 22 May 2026 08:57:14 -0400 Subject: [PATCH 01/29] Add Tuning Engines provider --- docs/openai-compatible-providers.md | 9 +++++++++ src/shared/provider-preset-catalog.ts | 17 +++++++++++++++++ tests/unit/api/provider-presets.test.ts | 13 +++++++++++++ 3 files changed, 39 insertions(+) diff --git a/docs/openai-compatible-providers.md b/docs/openai-compatible-providers.md index a874d227..235b7363 100644 --- a/docs/openai-compatible-providers.md +++ b/docs/openai-compatible-providers.md @@ -6,6 +6,7 @@ your API profile points at an OpenAI-compatible chat completions endpoint. This is useful for providers such as: - Hugging Face Inference Providers +- Tuning Engines - OpenRouter - Ollama - llama.cpp servers @@ -48,10 +49,18 @@ Create or reuse an API profile that points at an OpenAI-compatible endpoint: ccs api create --preset hf ``` +For Tuning Engines: + +```bash +ccs api create --preset te +``` + Then you can use the profile directly: ```bash ccs hf +# or +ccs te ``` CCS detects that the profile is OpenAI-compatible and auto-routes Claude Code diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index 5d934789..d40413a9 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -12,6 +12,7 @@ export const PROVIDER_PRESET_IDS = [ 'openrouter', 'alibaba-coding-plan', 'huggingface', + 'tuningengines', 'ollama', 'llamacpp', 'anthropic', @@ -61,6 +62,8 @@ export const PROVIDER_PRESET_ALIASES: Readonly> alibaba: 'alibaba-coding-plan', acp: 'alibaba-coding-plan', hf: 'huggingface', + te: 'tuningengines', + tuning: 'tuningengines', }); const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ @@ -153,6 +156,20 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ defaultTarget: 'droid', badge: 'Router', }, + { + id: 'tuningengines', + name: 'Tuning Engines', + description: 'Governed OpenAI-compatible gateway for routed intelligence', + baseUrl: 'https://api.tuningengines.com/v1', + defaultProfileName: 'te', + defaultModel: 'llama-3.3-70b-fp8', + apiKeyPlaceholder: 'sk-te-...', + apiKeyHint: 'Create an inference key at app.tuningengines.com/inference/keys', + category: 'alternative', + requiresApiKey: true, + defaultTarget: 'droid', + badge: 'Governed gateway', + }, { id: 'glm', name: 'GLM', diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index 8d2dc167..b37d1f9a 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -92,6 +92,19 @@ describe('provider-presets', () => { expect(isValidPresetId('hf')).toBe(true); }); + it('resolves Tuning Engines preset metadata and aliases', () => { + const preset = getPresetById('tuningengines'); + expect(preset?.id).toBe('tuningengines'); + expect(preset?.baseUrl).toBe('https://api.tuningengines.com/v1'); + expect(preset?.defaultProfileName).toBe('te'); + expect(preset?.defaultModel).toBe('llama-3.3-70b-fp8'); + expect(preset?.defaultTarget).toBe('droid'); + expect(preset?.apiKeyPlaceholder).toBe('sk-te-...'); + expect(getPresetById('te')?.id).toBe('tuningengines'); + expect(getPresetById('tuning')?.id).toBe('tuningengines'); + expect(isValidPresetId('te')).toBe(true); + }); + it('uses OpenRouter v1 as the OpenAI-compatible API root', () => { const preset = getPresetById('openrouter'); expect(preset?.baseUrl).toBe('https://openrouter.ai/api/v1'); From 2aaad43fd7940f3d51781a665b236dac6add1bf2 Mon Sep 17 00:00:00 2001 From: ReWiG Date: Fri, 19 Jun 2026 21:45:47 +0300 Subject: [PATCH 02/29] fix(proxy): forward stream_options.include_usage to OpenAI-compatible upstreams Claude Code's status line and the session log showed 0 tokens for every streaming response routed through the OpenAI-compatible proxy. The root cause was that the upstream request did not include `stream_options: { include_usage: true }`, so the provider (LiteLLM and other OpenAI-compatible endpoints) never emitted the final SSE chunk that carries `usage`. The proxy's stream parser was already wired to forward that chunk into an Anthropic-shaped `message_delta` event but had nothing to forward. Set `stream_options.include_usage` whenever the incoming Anthropic request is streamed. Non-streamed requests are unaffected. Ref: https://platform.openai.com/docs/api-reference/chat-streaming (search 'include_usage') --- src/proxy/transformers/request-transformer.ts | 13 ++++++++ .../transformers/request-transformer.test.ts | 32 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index d1bfbb2d..9083c1da 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -101,6 +101,9 @@ interface OpenAIMessage { export interface ProxyOpenAIRequest { model?: string; stream: boolean; + stream_options?: { + include_usage: boolean; + }; reasoning_effort?: string; reasoning?: { enabled: boolean; @@ -728,6 +731,16 @@ export class ProxyRequestTransformer { ? source.model.trim() : undefined, stream: source.stream === true, + // Ask the upstream provider to emit a final SSE chunk with token usage. + // OpenAI-compatible endpoints (and most proxies that follow the spec, + // including LiteLLM) only include `usage` in the streamed response when + // `stream_options.include_usage` is set on the request. Without this + // flag the proxy's stream parser still writes Anthropic-shaped events + // with `usage: { input_tokens: 0, output_tokens: 0 }`, so Claude Code's + // status line and the session log show 0 tokens even though the call + // was billed. See: + // https://platform.openai.com/docs/api-reference/chat-streaming + ...(source.stream === true ? { stream_options: { include_usage: true } } : {}), messages: coalesceMessages(allMessages), max_tokens: asNumber(source.max_tokens), temperature: asNumber(source.temperature), diff --git a/tests/unit/proxy/transformers/request-transformer.test.ts b/tests/unit/proxy/transformers/request-transformer.test.ts index 1047c2b1..f3258f07 100644 --- a/tests/unit/proxy/transformers/request-transformer.test.ts +++ b/tests/unit/proxy/transformers/request-transformer.test.ts @@ -115,4 +115,36 @@ describe('ProxyRequestTransformer', () => { expect(result.stop).toEqual(['A']); expect(result.metadata).toBeUndefined(); }); + + it('sets stream_options.include_usage for streaming requests so upstreams return token usage', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + stream: true, + messages: [{ role: 'user', content: 'ping' }], + }); + + expect(result.stream).toBe(true); + expect(result.stream_options).toEqual({ include_usage: true }); + }); + + it('omits stream_options for non-streaming requests', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + stream: false, + messages: [{ role: 'user', content: 'ping' }], + }); + + expect(result.stream).toBe(false); + expect(result.stream_options).toBeUndefined(); + }); + + it('omits stream_options when the upstream flag is absent', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + messages: [{ role: 'user', content: 'ping' }], + }); + + expect(result.stream).toBe(false); + expect(result.stream_options).toBeUndefined(); + }); }); From b889a758b83bfc292f2e470043a8b3e801b65169 Mon Sep 17 00:00:00 2001 From: VC Date: Fri, 19 Jun 2026 17:02:52 -0400 Subject: [PATCH 03/29] docs: normalize Tuning Engines provider preset --- src/shared/provider-preset-catalog.ts | 6 +++--- tests/unit/api/provider-presets.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index d40413a9..0f407bd3 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -159,16 +159,16 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ { id: 'tuningengines', name: 'Tuning Engines', - description: 'Governed OpenAI-compatible gateway for routed intelligence', + description: 'OpenAI-compatible chat completions gateway', baseUrl: 'https://api.tuningengines.com/v1', defaultProfileName: 'te', - defaultModel: 'llama-3.3-70b-fp8', + defaultModel: 'gpt-4o', apiKeyPlaceholder: 'sk-te-...', apiKeyHint: 'Create an inference key at app.tuningengines.com/inference/keys', category: 'alternative', requiresApiKey: true, defaultTarget: 'droid', - badge: 'Governed gateway', + badge: 'Gateway', }, { id: 'glm', diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index b37d1f9a..92f885d8 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -97,7 +97,7 @@ describe('provider-presets', () => { expect(preset?.id).toBe('tuningengines'); expect(preset?.baseUrl).toBe('https://api.tuningengines.com/v1'); expect(preset?.defaultProfileName).toBe('te'); - expect(preset?.defaultModel).toBe('llama-3.3-70b-fp8'); + expect(preset?.defaultModel).toBe('gpt-4o'); expect(preset?.defaultTarget).toBe('droid'); expect(preset?.apiKeyPlaceholder).toBe('sk-te-...'); expect(getPresetById('te')?.id).toBe('tuningengines'); From 99ed8090b152974dacf1219e94e36246664bd302 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 20 Jun 2026 13:40:23 +0800 Subject: [PATCH 04/29] =?UTF-8?q?fix(cliproxy):=20=E6=8C=89=20backend=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=20provider=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/cliproxy-profile-bridge.test.ts | 58 ++++++++--- src/api/services/profile-writer.ts | 27 ++++-- .../env-builder-provider-url.test.ts | 70 ++++++++++++-- src/cliproxy/config/env-builder.ts | 60 ++++-------- src/cliproxy/config/provider-route.ts | 42 ++++++++ .../env-resolver-codex-fallback.test.ts | 56 ++++++++++- src/cliproxy/executor/env-resolver.ts | 6 +- src/cliproxy/executor/proxy-chain-builder.ts | 8 +- src/targets/codex-cliproxy-provider-config.ts | 95 +++++++++++++++---- .../unit/api/cliproxy-profile-bridge.test.ts | 25 ++++- .../codex-cliproxy-provider-config.test.ts | 68 ++++++++++++- .../unit/utils/browser/browser-setup.test.ts | 35 ++++++- .../profile-routes-cliproxy-bridge.test.ts | 6 ++ 13 files changed, 450 insertions(+), 106 deletions(-) create mode 100644 src/cliproxy/config/provider-route.ts diff --git a/src/api/services/__tests__/cliproxy-profile-bridge.test.ts b/src/api/services/__tests__/cliproxy-profile-bridge.test.ts index 7948a85e..f5b622ce 100644 --- a/src/api/services/__tests__/cliproxy-profile-bridge.test.ts +++ b/src/api/services/__tests__/cliproxy-profile-bridge.test.ts @@ -1,14 +1,13 @@ /** - * Regression tests for the `ccs api create --cliproxy-provider claude` bridge path. + * Regression tests for the `ccs api create --cliproxy-provider ` bridge path. * - * PR #1554 fixed the main CLIProxy env-builder to use the root URL for the built-in - * claude provider. This file locks the same rule on the parallel api-create bridge path + * The bridge path must use the same route rules as the main CLIProxy env-builder * (resolveCliproxyBridgeProfile / listCliproxyBridgeProviders) so both paths stay - * consistent. + * consistent across backends. * - * Background: CLIProxyAPI registers /v1/messages at the ROOT. The /api/provider/ - * prefix is a Plus-only route for non-Claude providers. Using /api/provider/claude - * returns 404 on the base CLIProxyAPI installation. + * Background: the original CLIProxyAPI backend registers Claude-compatible routes at + * the root. The /api/provider/ prefix is a Plus-only route. Using provider-scoped + * routes against the original backend returns 404. */ import * as fs from 'fs'; @@ -23,7 +22,7 @@ import { resolveCliproxyBridgeMetadata } from '../cliproxy-profile-bridge'; import { invalidateConfigCache } from '../../../config/config-loader-facade'; import { clearConfigCache } from '../../../cliproxy/config/base-config-loader'; -describe('cliproxy-profile-bridge: claude provider uses root URL', () => { +describe('cliproxy-profile-bridge: backend-aware provider route paths', () => { let tempHome: string; let originalCcsHome: string | undefined; @@ -37,12 +36,28 @@ describe('cliproxy-profile-bridge: claude provider uses root URL', () => { }); afterEach(() => { - process.env.CCS_HOME = originalCcsHome; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } invalidateConfigCache(); clearConfigCache(); fs.rmSync(tempHome, { recursive: true, force: true }); }); + function writeBackendConfig(backend: 'original' | 'plus'): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'), + 'utf8' + ); + invalidateConfigCache(); + clearConfigCache(); + } + // ── resolveCliproxyBridgeProfile ────────────────────────────────────────── it('resolveCliproxyBridgeProfile(claude) produces root base URL', () => { @@ -65,13 +80,20 @@ describe('cliproxy-profile-bridge: claude provider uses root URL', () => { expect(profile.models.haiku).toBe(''); }); - it('resolveCliproxyBridgeProfile(gemini) still uses scoped /api/provider path', () => { + it('resolveCliproxyBridgeProfile(gemini) uses root URL on the original backend', () => { const profile = resolveCliproxyBridgeProfile('gemini'); - expect(profile.baseUrl).toContain('/api/provider/gemini'); - expect(profile.routePath).toBe('/api/provider/gemini'); + expect(profile.baseUrl).toBe('http://127.0.0.1:8317/'); + expect(profile.routePath).toBe('/'); }); - it('resolveCliproxyBridgeProfile(codex) still uses scoped /api/provider path', () => { + it('resolveCliproxyBridgeProfile(codex) uses root URL on the original backend', () => { + const profile = resolveCliproxyBridgeProfile('codex'); + expect(profile.baseUrl).toBe('http://127.0.0.1:8317/'); + expect(profile.routePath).toBe('/'); + }); + + it('resolveCliproxyBridgeProfile(codex) uses scoped path on the plus backend', () => { + writeBackendConfig('plus'); const profile = resolveCliproxyBridgeProfile('codex'); expect(profile.baseUrl).toContain('/api/provider/codex'); expect(profile.routePath).toBe('/api/provider/codex'); @@ -87,7 +109,15 @@ describe('cliproxy-profile-bridge: claude provider uses root URL', () => { expect(claudeInfo?.routePath).not.toContain('/api/provider/claude'); }); - it('listCliproxyBridgeProviders keeps scoped routePaths for non-claude providers', () => { + it('listCliproxyBridgeProviders shows root routePaths for original-backend providers', () => { + const providers = listCliproxyBridgeProviders(); + for (const info of providers) { + expect(info.routePath).toBe('/'); + } + }); + + it('listCliproxyBridgeProviders keeps scoped routePaths for plus-backend non-claude providers', () => { + writeBackendConfig('plus'); const providers = listCliproxyBridgeProviders(); for (const info of providers) { if (info.provider === 'claude') continue; diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 0c0df0e0..dc366eae 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -37,6 +37,7 @@ import { loadConfigSafe, mutateConfig, } from '../../config/config-loader-facade'; +import { ProfileError } from '../../errors/error-types'; /** Check if URL is an OpenRouter endpoint */ function isOpenRouterUrl(baseUrl: string): boolean { @@ -333,19 +334,27 @@ export function createCliproxyBridgeProfile( resolved.target, provider ); + const detectedBridge = resolveCliproxyBridgeMetadata({ + env: { + ANTHROPIC_BASE_URL: resolved.baseUrl, + ANTHROPIC_AUTH_TOKEN: resolved.apiKey, + }, + }); return { ...result, name: resolved.name, provider, target: resolved.target, - cliproxyBridge: - resolveCliproxyBridgeMetadata({ - env: { - ANTHROPIC_BASE_URL: resolved.baseUrl, - ANTHROPIC_AUTH_TOKEN: resolved.apiKey, - }, - }) ?? null, + cliproxyBridge: detectedBridge ?? { + provider, + providerDisplayName: resolved.providerDisplayName, + routePath: resolved.routePath, + currentBaseUrl: resolved.baseUrl, + source: resolved.source, + usesCurrentTarget: true, + usesCurrentAuthToken: true, + }, }; } @@ -361,7 +370,7 @@ export function updateApiProfileTarget( if (isUnifiedMode()) { mutateConfig((config) => { if (!config.profiles[name]) { - throw new Error(`API profile not found: ${name}`); + throw new ProfileError(`API profile not found: ${name}`, name); } if (target === 'claude') { @@ -412,7 +421,7 @@ function removeApiProfileUnified(name: string): void { const profile = config.profiles[name]; if (!profile) { - throw new Error(`API profile not found: ${name}`); + throw new ProfileError(`API profile not found: ${name}`, name); } if (profile.settings) { diff --git a/src/cliproxy/config/__tests__/env-builder-provider-url.test.ts b/src/cliproxy/config/__tests__/env-builder-provider-url.test.ts index 83bd4d72..37d8b932 100644 --- a/src/cliproxy/config/__tests__/env-builder-provider-url.test.ts +++ b/src/cliproxy/config/__tests__/env-builder-provider-url.test.ts @@ -3,6 +3,8 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { ensureProviderSettings, getEffectiveEnvVars } from '../env-builder'; +import { invalidateConfigCache } from '../../../config/config-loader-facade'; +import { clearConfigCache } from '../base-config-loader'; interface EnvSettings { ANTHROPIC_BASE_URL: string; @@ -30,14 +32,35 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { originalCcsHome = process.env.CCS_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-url-')); settingsPath = path.join(tempHome, 'codex.settings.json'); + process.env.CCS_HOME = tempHome; + invalidateConfigCache(); + clearConfigCache(); }); afterEach(() => { - process.env.CCS_HOME = originalCcsHome; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + invalidateConfigCache(); + clearConfigCache(); fs.rmSync(tempHome, { recursive: true, force: true }); }); - it('rewrites local root URL to provider endpoint without stripping codex effort suffixes', () => { + function writeBackendConfig(backend: 'original' | 'plus'): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'), + 'utf8' + ); + invalidateConfigCache(); + clearConfigCache(); + } + + it('keeps local root URL for the original backend without stripping codex effort suffixes', () => { writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', @@ -48,7 +71,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); const env = getEffectiveEnvVars('codex', 8317, settingsPath); - expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); @@ -63,7 +86,37 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); }); - it('rewrites wrong local provider path to the requested provider', () => { + it('rewrites local root URL to provider endpoint for the plus backend', () => { + writeBackendConfig('plus'); + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', + }); + + const env = getEffectiveEnvVars('codex', 8317, settingsPath); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); + }); + + it('rewrites wrong local provider path to root for the original backend', () => { + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', + }); + + const env = getEffectiveEnvVars('codex', 8317, settingsPath); + expect(env.ANTHROPIC_BASE_URL).toBe('http://localhost:8317'); + }); + + it('rewrites wrong local provider path to the requested provider for the plus backend', () => { + writeBackendConfig('plus'); writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', @@ -285,7 +338,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('repairs existing provider settings files that are missing env keys', () => { - process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); fs.writeFileSync( @@ -308,7 +360,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { hooks?: Record; }; expect(repaired.hooks?.PreToolUse).toBeDefined(); - expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/agy'); + expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); expect(repaired.env?.ANTHROPIC_MODEL).toBeDefined(); expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeDefined(); expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeDefined(); @@ -316,7 +368,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('imports legacy cursor settings into the dedicated provider path without reusing legacy transport auth', () => { - process.env.CCS_HOME = tempHome; + writeBackendConfig('plus'); const legacySettingsPath = path.join(tempHome, '.ccs', 'cursor.settings.json'); const providerSettingsPath = path.join( tempHome, @@ -375,7 +427,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => { - process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); fs.writeFileSync( @@ -422,7 +473,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('preserves codex effort-suffixed IDs during ensureProviderSettings', () => { - process.env.CCS_HOME = tempHome; const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true }); fs.writeFileSync( @@ -458,6 +508,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { env?: Record; presets?: Array>; }; + expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); @@ -469,7 +520,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('recovers malformed provider settings files by writing defaults and backup copy', () => { - process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); fs.writeFileSync(agySettingsPath, '{"env": {"ANTHROPIC_MODEL": "claude-sonnet-4-6-thinking",}'); diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index d99bd8f4..6cc77712 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -36,6 +36,10 @@ import { getOutputLimitsEnv, getCcsDir, } from '../../config/config-loader-facade'; +import { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route'; +import { ConfigError } from '../../errors/error-types'; + +export { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route'; /** Settings file structure for user overrides */ interface ProviderSettings { @@ -205,14 +209,12 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin /** * Get environment variables for Claude CLI (bundled defaults) - * Uses provider-specific endpoints (e.g., /api/provider/gemini) for explicit routing - * except for the built-in claude provider. + * Uses the backend-aware route path for the selected provider. * - * Root-URL exception: the claude provider always uses the CLIProxy ROOT endpoint - * (http://127.0.0.1:) instead of /api/provider/claude. CLIProxyAPI's Claude Code - * contract registers /v1/messages at the root; the /api/provider/ prefix is a Plus-only - * feature for non-Claude providers. buildCliproxyProviderPath() encodes this rule and is - * used here and by the api-create bridge path so both remain consistent. + * Root-URL rule: the original CLIProxy backend uses the root endpoint + * (http://127.0.0.1:) and routes by model. The /api/provider/ prefix is + * only used for Plus-backend non-Claude providers. buildCliproxyProviderPath() + * encodes this rule and is reused by the api-create bridge path. * * For the claude built-in provider the model env vars are intentionally omitted so that * the user's own Claude Code /model selection is honored end-to-end (model-neutral passthrough). @@ -375,25 +377,6 @@ function ensureRequiredEnvVars( /** Localhost hostnames used for local CLIProxy endpoints */ const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']); -/** - * Return the CLIProxy route path for a provider. - * - * - claude uses the root path (empty string → "/" after buildProxyUrl normalises it) - * because CLIProxyAPI's Claude Code contract registers /v1/messages at the root; the - * /api/provider/ prefix is Plus-only and only for non-Claude providers. - * - all other providers use the scoped /api/provider/ path. - * - * Exported so the profile-bridge can reuse the same rule (DRY). - */ -export function buildCliproxyProviderPath(provider: CLIProxyProvider): string { - return provider === 'claude' ? '' : `/api/provider/${provider}`; -} - -function buildLocalProviderBaseUrl(provider: CLIProxyProvider, port: number): string { - const rootUrl = `http://127.0.0.1:${port}`; - return provider === 'claude' ? rootUrl : `${rootUrl}/api/provider/${provider}`; -} - /** * Normalize local CLIProxy endpoint to the expected provider route. * Only rewrites localhost URLs that target the active local port. @@ -415,11 +398,11 @@ function normalizeLocalProviderBaseUrl( : 80; if (!Number.isFinite(effectivePort) || effectivePort !== port) return baseUrl; - if (provider === 'claude') { + const expectedPath = buildCliproxyProviderPath(provider); + if (expectedPath === '') { return parsed.origin; } - const expectedPath = `/api/provider/${provider}`; if (parsed.pathname === expectedPath && !parsed.search && !parsed.hash) return baseUrl; parsed.pathname = expectedPath; @@ -437,7 +420,6 @@ function normalizeLocalProviderBaseUrl( */ function rewriteLocalhostUrls( envVars: NodeJS.ProcessEnv, - provider: CLIProxyProvider, remoteConfig: RemoteProxyRewriteConfig ): NodeJS.ProcessEnv { const result = { ...envVars }; @@ -458,10 +440,7 @@ function rewriteLocalhostUrls( const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; const remoteRootUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`; - const remoteBaseUrl = - provider === 'claude' ? remoteRootUrl : `${remoteRootUrl}/api/provider/${provider}`; - - result.ANTHROPIC_BASE_URL = remoteBaseUrl; + result.ANTHROPIC_BASE_URL = remoteRootUrl; // Update auth token if provided if (remoteConfig.authToken) { @@ -517,7 +496,7 @@ export function getEffectiveEnvVars( envVars = ensureRequiredEnvVars(envVars, provider, port); // Apply remote rewrite if configured if (remoteRewriteConfig) { - envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); + envVars = rewriteLocalhostUrls(envVars, remoteRewriteConfig); } return envVars; } @@ -553,7 +532,7 @@ export function getEffectiveEnvVars( envVars = ensureRequiredEnvVars(envVars, provider, port); // Apply remote rewrite if configured if (remoteRewriteConfig) { - envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); + envVars = rewriteLocalhostUrls(envVars, remoteRewriteConfig); } return envVars; } @@ -696,10 +675,11 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { let parsed: Record; try { const value = JSON.parse(rawContent) as unknown; - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('settings root must be an object'); + if (value && typeof value === 'object' && !Array.isArray(value)) { + parsed = value as Record; + } else { + throw new SyntaxError('settings root must be an object'); } - parsed = value as Record; } catch { // Preserve corrupt payload for manual inspection, then recover with defaults. const backupPath = `${settingsPath}.corrupt-${Date.now()}`; @@ -744,7 +724,7 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { } } - if (provider === 'claude' && typeof mergedEnv.ANTHROPIC_BASE_URL === 'string') { + if (typeof mergedEnv.ANTHROPIC_BASE_URL === 'string') { const normalizedBaseUrl = normalizeLocalProviderBaseUrl( mergedEnv.ANTHROPIC_BASE_URL, provider, @@ -1002,7 +982,7 @@ export function getCompositeEnvVars( // If default tier is missing, we cannot proceed meaningfully if (!defaultModel) { - throw new Error(`Missing model for default tier '${defaultTier}'`); + throw new ConfigError(`Missing model for default tier '${defaultTier}'`); } // Determine base URL and auth token based on remote vs local mode diff --git a/src/cliproxy/config/provider-route.ts b/src/cliproxy/config/provider-route.ts new file mode 100644 index 00000000..0ccac5c3 --- /dev/null +++ b/src/cliproxy/config/provider-route.ts @@ -0,0 +1,42 @@ +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; +import { DEFAULT_BACKEND } from '../binary/platform-detector'; +import type { CLIProxyBackend, CLIProxyProvider } from '../types'; + +export function getConfiguredCliproxyBackend(): CLIProxyBackend { + try { + const backend = loadOrCreateUnifiedConfig().cliproxy?.backend; + return backend === 'plus' || backend === 'original' ? backend : DEFAULT_BACKEND; + } catch { + return DEFAULT_BACKEND; + } +} + +export function usesScopedProviderRoutes( + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): boolean { + return backend === 'plus'; +} + +/** + * Return the CLIProxy route path for a provider. + * + * The original backend routes Claude-compatible traffic at the root and relies + * on model-based provider selection. The Plus backend exposes provider-scoped + * routes for non-Claude providers. + */ +export function buildCliproxyProviderPath( + provider: CLIProxyProvider, + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): string { + if (provider === 'claude') return ''; + return usesScopedProviderRoutes(backend) ? `/api/provider/${provider}` : ''; +} + +export function buildLocalProviderBaseUrl( + provider: CLIProxyProvider, + port: number, + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): string { + const rootUrl = `http://127.0.0.1:${port}`; + return `${rootUrl}${buildCliproxyProviderPath(provider, backend)}`; +} diff --git a/src/cliproxy/executor/__tests__/env-resolver-codex-fallback.test.ts b/src/cliproxy/executor/__tests__/env-resolver-codex-fallback.test.ts index 01fa203d..fa185182 100644 --- a/src/cliproxy/executor/__tests__/env-resolver-codex-fallback.test.ts +++ b/src/cliproxy/executor/__tests__/env-resolver-codex-fallback.test.ts @@ -1,11 +1,27 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { buildClaudeEnvironment, resolveCliproxyImageAnalysisEnv } from '../env-resolver'; import type { ImageAnalysisStatus } from '../../../utils/hooks'; +import { invalidateConfigCache } from '../../../config/config-loader-facade'; +import { clearConfigCache } from '../../config/base-config-loader'; const tempDirs: string[] = []; +let originalCcsHome: string | undefined; +let configHome = ''; + +function writeBackendConfig(backend: 'original' | 'plus'): void { + const ccsDir = path.join(configHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'), + 'utf8' + ); + invalidateConfigCache(); + clearConfigCache(); +} function createCodexSettingsFile(models: { defaultModel: string; @@ -70,7 +86,23 @@ function createImageAnalysisStatus( } describe('buildClaudeEnvironment codex fallback normalization', () => { + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-resolver-home-')); + tempDirs.push(configHome); + process.env.CCS_HOME = configHome; + invalidateConfigCache(); + clearConfigCache(); + }); + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + invalidateConfigCache(); + clearConfigCache(); while (tempDirs.length > 0) { const tempDir = tempDirs.pop(); if (tempDir) { @@ -99,6 +131,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex(xhigh)'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex(high)'); expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini(medium)'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); }); it('keeps codex effort aliases when reasoning proxy is active', () => { @@ -122,6 +155,27 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444'); + }); + + it('keeps scoped codex reasoning URL when the plus backend is active', () => { + writeBackendConfig('plus'); + const settingsPath = createCodexSettingsFile({ + defaultModel: 'gpt-5.3-codex-high', + opusModel: 'gpt-5.3-codex-xhigh', + sonnetModel: 'gpt-5.3-codex-high', + haikuModel: 'gpt-5.4-mini-medium', + }); + + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + codexReasoningPort: 9444, + verbose: false, + }); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex'); }); diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index ccce224b..1883f2f1 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -39,6 +39,7 @@ import { import type { ProxyTarget } from '../proxy/proxy-target-resolver'; import { getEffectiveApiKey } from '../auth/auth-token-manager'; import { isSettings, type Settings } from '../../types/config'; +import { buildCliproxyProviderPath } from '../config/provider-route'; export interface RemoteProxyConfig { host: string; @@ -373,7 +374,10 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record | null): string return CODEX_CLIPROXY_PROVIDER_ENV_KEY; } -function isProviderReady(provider: Record, envKey: string): boolean { +const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']); + +function getEffectiveUrlPort(url: URL): number { + if (url.port) return Number.parseInt(url.port, 10); + return url.protocol === 'https:' ? 443 : 80; +} + +function isManagedLocalUrl(url: URL): boolean { + return ['http:', 'https:'].includes(url.protocol) && LOCALHOST_NAMES.has(url.hostname); +} + +function shouldUseManagedLocalBaseUrl(current: URL, expected: URL): boolean { + if (!isManagedLocalUrl(current) || !isManagedLocalUrl(expected)) { + return false; + } + + const currentPort = getEffectiveUrlPort(current); + const expectedPort = getEffectiveUrlPort(expected); + if (currentPort !== expectedPort) { + return true; + } + + const currentPath = current.pathname.replace(/\/+$/, '') || '/'; + const expectedPath = expected.pathname.replace(/\/+$/, '') || '/'; + return currentPath !== expectedPath; +} + +function resolveProviderBaseUrl( + provider: Record, + fallbackBaseUrl: string +): string { + const baseUrl = provider.base_url; + if (!isValidCodexCliproxyBaseUrl(baseUrl)) { + return fallbackBaseUrl; + } + + const trimmed = baseUrl.trim(); + try { + const current = new URL(trimmed); + const expected = new URL(fallbackBaseUrl); + if (shouldUseManagedLocalBaseUrl(current, expected)) { + return fallbackBaseUrl; + } + } catch { + return fallbackBaseUrl; + } + + return trimmed; +} + +function isProviderReady( + provider: Record, + envKey: string, + expectedBaseUrl: string +): boolean { return ( provider.name === CODEX_CLIPROXY_PROVIDER_NAME && isValidCodexCliproxyBaseUrl(provider.base_url) && + typeof provider.base_url === 'string' && + resolveProviderBaseUrl(provider, expectedBaseUrl) === provider.base_url.trim() && provider.env_key === envKey && provider.wire_api === 'responses' && provider.requires_openai_auth === false && @@ -92,17 +150,6 @@ function buildProviderConfig(baseUrl: string, envKey: string): Record, - fallbackBaseUrl: string -): string { - const baseUrl = provider.base_url; - if (isValidCodexCliproxyBaseUrl(baseUrl)) { - return baseUrl.trim(); - } - return fallbackBaseUrl; -} - function appendProviderBlock(rawText: string, baseUrl: string): string { const prefix = rawText.trimEnd(); const providerBlock = stringifyTomlObject({ @@ -135,10 +182,16 @@ export async function ensureCodexCliproxyProviderConfig( const fileProbe = await probeTomlObjectFile(configPath, 'Codex user config', displayPath); if (fileProbe.diagnostics.readError) { - throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.readError}`); + throw new ConfigError( + `Cannot repair ${displayPath}: ${fileProbe.diagnostics.readError}`, + configPath + ); } if (fileProbe.diagnostics.parseError) { - throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.parseError}`); + throw new ConfigError( + `Cannot repair ${displayPath}: ${fileProbe.diagnostics.parseError}`, + configPath + ); } const config = fileProbe.config ?? {}; @@ -148,7 +201,10 @@ export async function ensureCodexCliproxyProviderConfig( const normalizedModelAlias = normalizeTopLevelCodexModelAlias(config); if (modelProvidersValue !== undefined && !providers) { - throw new Error(`Cannot repair ${displayPath}: [model_providers] must be a table.`); + throw new ConfigError( + `Cannot repair ${displayPath}: [model_providers] must be a table.`, + configPath + ); } if (!providers || !Object.prototype.hasOwnProperty.call(providers, CODEX_CLIPROXY_PROVIDER_ID)) { @@ -174,13 +230,14 @@ export async function ensureCodexCliproxyProviderConfig( const currentProvider = asObject(providers[CODEX_CLIPROXY_PROVIDER_ID]); if (!currentProvider) { - throw new Error( - `Cannot repair ${displayPath}: [model_providers.${CODEX_CLIPROXY_PROVIDER_ID}] must be a table.` + throw new ConfigError( + `Cannot repair ${displayPath}: [model_providers.${CODEX_CLIPROXY_PROVIDER_ID}] must be a table.`, + configPath ); } const envKey = resolveProviderEnvKey(currentProvider); - const providerReady = isProviderReady(currentProvider, envKey); + const providerReady = isProviderReady(currentProvider, envKey, expectedBaseUrl); if (!providerReady) { providers[CODEX_CLIPROXY_PROVIDER_ID] = { diff --git a/tests/unit/api/cliproxy-profile-bridge.test.ts b/tests/unit/api/cliproxy-profile-bridge.test.ts index 0f35ec2b..b4b7425e 100644 --- a/tests/unit/api/cliproxy-profile-bridge.test.ts +++ b/tests/unit/api/cliproxy-profile-bridge.test.ts @@ -8,6 +8,8 @@ import { resolveCliproxyBridgeProfile, suggestCliproxyBridgeName, } from '../../../src/api/services/cliproxy-profile-bridge'; +import { invalidateConfigCache } from '../../../src/config/config-loader-facade'; +import { clearConfigCache } from '../../../src/cliproxy/config/base-config-loader'; describe('cliproxy-profile-bridge', () => { let tempHome = ''; @@ -17,6 +19,8 @@ describe('cliproxy-profile-bridge', () => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-bridge-')); originalCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tempHome; + invalidateConfigCache(); + clearConfigCache(); }); afterEach(() => { @@ -25,18 +29,32 @@ describe('cliproxy-profile-bridge', () => { } else { process.env.CCS_HOME = originalCcsHome; } + invalidateConfigCache(); + clearConfigCache(); if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } }); - it('resolves routed profile payload for a local CLIProxy provider', () => { + function writeBackendConfig(backend: 'original' | 'plus'): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'), + 'utf8' + ); + invalidateConfigCache(); + clearConfigCache(); + } + + it('resolves routed profile payload for a local original-backend CLIProxy provider', () => { const bridge = resolveCliproxyBridgeProfile('gemini'); expect(bridge.name).toBe('gemini-api'); - expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/api/provider/gemini'); - expect(bridge.routePath).toBe('/api/provider/gemini'); + expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/'); + expect(bridge.routePath).toBe('/'); expect(bridge.models.default.length).toBeGreaterThan(0); }); @@ -49,6 +67,7 @@ describe('cliproxy-profile-bridge', () => { }); it('detects CLIProxy-backed profile metadata and normalizes localhost loopback URLs', () => { + writeBackendConfig('plus'); const metadata = resolveCliproxyBridgeMetadata({ env: { ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/gemini', diff --git a/tests/unit/targets/codex-cliproxy-provider-config.test.ts b/tests/unit/targets/codex-cliproxy-provider-config.test.ts index 8edf4edd..62792f81 100644 --- a/tests/unit/targets/codex-cliproxy-provider-config.test.ts +++ b/tests/unit/targets/codex-cliproxy-provider-config.test.ts @@ -6,24 +6,61 @@ import { buildCodexCliproxyProviderBaseUrl, ensureCodexCliproxyProviderConfig, } from '../../../src/targets/codex-cliproxy-provider-config'; +import { invalidateConfigCache } from '../../../src/config/config-loader-facade'; +import { clearConfigCache } from '../../../src/cliproxy/config/base-config-loader'; describe('codex cliproxy provider config repair', () => { let tempHome: string; let codexHome: string; let configPath: string; let env: NodeJS.ProcessEnv; + let originalCcsHome: string | undefined; beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-provider-config-')); codexHome = path.join(tempHome, '.codex'); configPath = path.join(codexHome, 'config.toml'); env = { CODEX_HOME: codexHome } as NodeJS.ProcessEnv; + process.env.CCS_HOME = tempHome; + invalidateConfigCache(); + clearConfigCache(); }); afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + invalidateConfigCache(); + clearConfigCache(); fs.rmSync(tempHome, { recursive: true, force: true }); }); + function writeBackendConfig(backend: 'original' | 'plus'): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'), + 'utf8' + ); + invalidateConfigCache(); + clearConfigCache(); + } + + it('uses the original backend root URL by default', () => { + expect(buildCodexCliproxyProviderBaseUrl(8317)).toBe('http://127.0.0.1:8317'); + }); + + it('uses the plus backend scoped Codex URL when configured', () => { + writeBackendConfig('plus'); + expect(buildCodexCliproxyProviderBaseUrl(8317)).toBe( + 'http://127.0.0.1:8317/api/provider/codex' + ); + }); + it('creates the cliproxy model provider when config.toml is missing', async () => { const result = await ensureCodexCliproxyProviderConfig(8317, env); @@ -32,6 +69,7 @@ describe('codex cliproxy provider config repair', () => { const rawText = fs.readFileSync(configPath, 'utf8'); expect(rawText).toContain('[model_providers.cliproxy]'); expect(rawText).toContain('name = "CLIProxy Codex"'); + expect(rawText).toContain('base_url = "http://127.0.0.1:8317"'); expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); expect(rawText).not.toContain('model_provider = "cliproxy"'); }); @@ -78,7 +116,7 @@ wire_api = "responses" expect(result.changed).toBe(true); expect(result.envKey).toBe('CLIPROXY_API_KEY'); const rawText = fs.readFileSync(configPath, 'utf8'); - expect(rawText).toContain('base_url = "http://localhost:8317/api/provider/codex"'); + expect(rawText).toContain('base_url = "http://127.0.0.1:9321"'); expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); expect(rawText).toContain('requires_openai_auth = false'); expect(rawText).toContain('supports_websockets = false'); @@ -142,7 +180,29 @@ supports_websockets = false expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText); }); - it('leaves a ready localhost provider unchanged', async () => { + it('repairs a stale ready localhost provider to the original backend root URL', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + const rawText = `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "http://localhost:8317/api/provider/codex" +env_key = "CLIPROXY_API_KEY" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +`; + fs.writeFileSync(configPath, rawText, 'utf8'); + + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + const repairedText = fs.readFileSync(configPath, 'utf8'); + expect(repairedText).toContain('base_url = "http://127.0.0.1:8317"'); + expect(repairedText).not.toContain('/api/provider/codex'); + }); + + it('leaves a ready plus-backend localhost provider unchanged', async () => { + writeBackendConfig('plus'); fs.mkdirSync(codexHome, { recursive: true }); const rawText = `[model_providers.cliproxy] name = "CLIProxy Codex" @@ -188,7 +248,7 @@ supports_websockets = false [model_providers.cliproxy] name = "CLIProxy Codex" -base_url = "http://localhost:8317/api/provider/codex" +base_url = "http://127.0.0.1:8317" env_key = "CLIPROXY_API_KEY" wire_api = "responses" requires_openai_auth = false @@ -239,7 +299,7 @@ supports_websockets = false [model_providers.cliproxy] name = "CLIProxy Codex" -base_url = "http://localhost:8317/api/provider/codex" +base_url = "http://127.0.0.1:8317" env_key = "CLIPROXY_API_KEY" wire_api = "responses" requires_openai_auth = false diff --git a/tests/unit/utils/browser/browser-setup.test.ts b/tests/unit/utils/browser/browser-setup.test.ts index 755dc8d7..05530319 100644 --- a/tests/unit/utils/browser/browser-setup.test.ts +++ b/tests/unit/utils/browser/browser-setup.test.ts @@ -1,9 +1,12 @@ -import { afterEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { UnifiedConfig } from '../../../../src/config/unified-config-types'; -import { runBrowserSetup, type BrowserSetupDeps } from '../../../../src/utils/browser/browser-setup'; +import { + runBrowserSetup, + type BrowserSetupDeps, +} from '../../../../src/utils/browser/browser-setup'; function createUnifiedConfig(userDataDir: string): UnifiedConfig { return { @@ -96,8 +99,36 @@ function createUnifiedConfig(userDataDir: string): UnifiedConfig { describe('browser setup', () => { let tempDir = ''; + let originalBrowserUserDataDir: string | undefined; + let originalBrowserProfileDir: string | undefined; + let originalBrowserDevtoolsPort: string | undefined; + + beforeEach(() => { + originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + delete process.env.CCS_BROWSER_USER_DATA_DIR; + delete process.env.CCS_BROWSER_PROFILE_DIR; + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + }); afterEach(() => { + if (originalBrowserUserDataDir !== undefined) { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } else { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } + if (originalBrowserProfileDir !== undefined) { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } else { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } + if (originalBrowserDevtoolsPort !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } + if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; diff --git a/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts b/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts index d12a28d6..c98cc00a 100644 --- a/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts +++ b/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts @@ -5,6 +5,8 @@ import * as os from 'os'; import * as path from 'path'; import type { Server } from 'http'; import profileRoutes from '../../../src/web-server/routes/profile-routes'; +import { invalidateConfigCache } from '../../../src/config/config-loader-facade'; +import { clearConfigCache } from '../../../src/cliproxy/config/base-config-loader'; describe('profile-routes cliproxy bridge', () => { let server: Server; @@ -42,6 +44,8 @@ describe('profile-routes cliproxy bridge', () => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-cliproxy-bridge-')); originalCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tempHome; + invalidateConfigCache(); + clearConfigCache(); }); afterEach(() => { @@ -50,6 +54,8 @@ describe('profile-routes cliproxy bridge', () => { } else { delete process.env.CCS_HOME; } + invalidateConfigCache(); + clearConfigCache(); if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); From 66251c52786704088298098ad1562d5611195c43 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 20 Jun 2026 13:49:16 +0800 Subject: [PATCH 05/29] =?UTF-8?q?fix(build):=20=E4=BD=BF=E7=94=A8=20type-o?= =?UTF-8?q?nly=20=E5=AF=BC=E5=85=A5=E5=B9=B3=E5=8F=B0=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cliproxy/binary/platform-detector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cliproxy/binary/platform-detector.ts b/src/cliproxy/binary/platform-detector.ts index e5e9877c..14c3a07b 100644 --- a/src/cliproxy/binary/platform-detector.ts +++ b/src/cliproxy/binary/platform-detector.ts @@ -5,7 +5,7 @@ * Supports 6 platforms: darwin/linux/windows x amd64/arm64 */ -import { +import type { PlatformInfo, SupportedOS, SupportedArch, From 70a5f68a22e528cb22913e1aa3c62a4d53f63103 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 20 Jun 2026 13:59:55 +0800 Subject: [PATCH 06/29] =?UTF-8?q?chore(ci):=20=E9=87=8D=E6=96=B0=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=20PR=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 191396149ffa14d5bd6a59de91a594c6faabb6eb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:39:55 -0400 Subject: [PATCH 07/29] fix(auth): de-duplicate error output and validate legacy profiles in default exitWithError() already prints '[X] ', so the preceding console.log(fail()) calls double-printed every auth error. Remove the redundant logs and fold richer hints (e.g. --force) into the single exitWithError message. Also make 'auth default' accept a profile registered only in legacy profiles.json under unified mode. --- src/auth/commands/backup-command.ts | 3 +-- src/auth/commands/create-command.ts | 28 ++++++++++++-------------- src/auth/commands/default-command.ts | 1 - src/auth/commands/remove-command.ts | 4 +--- src/auth/commands/resources-command.ts | 3 +-- src/auth/commands/show-command.ts | 3 +-- src/auth/commands/types.ts | 3 +-- src/auth/profile-registry.ts | 12 ++++++++--- 8 files changed, 27 insertions(+), 30 deletions(-) diff --git a/src/auth/commands/backup-command.ts b/src/auth/commands/backup-command.ts index f6fe167d..8aa8b595 100644 --- a/src/auth/commands/backup-command.ts +++ b/src/auth/commands/backup-command.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { initUI, color, dim, fail, info, ok } from '../../utils/ui'; +import { initUI, color, dim, info, ok } from '../../utils/ui'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { @@ -44,7 +44,6 @@ export async function handleBackup(ctx: CommandContext, args: string[]): Promise }); if (!profileName) { - console.log(fail('Profile name is required')); console.log(''); console.log(`Usage: ${color('ccs auth backup [--json]', 'command')}`); exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index 05eacc4a..ee78ffba 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -53,7 +53,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise }); if (!profileName) { - console.log(fail('Profile name is required')); console.log(''); console.log( `Usage: ${color('ccs auth create [--force] [--bare] [--share-context] [--context-group ] [--deeper-continuity]', 'command')}` @@ -65,20 +64,21 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise } if (!isValidAccountProfileName(profileName)) { - const error = - 'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.'; - console.log(fail(error)); - console.log(''); - exitWithError(error, ExitCode.PROFILE_ERROR); + exitWithError( + 'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.', + ExitCode.PROFILE_ERROR + ); } // Check if profile already exists (check both legacy and unified) const existsLegacy = ctx.registry.hasProfile(profileName); const existsUnified = ctx.registry.hasAccountUnified(profileName); if (!force && (existsLegacy || existsUnified)) { - console.log(fail(`Profile already exists: ${profileName}`)); - console.log(` Use ${color('--force', 'command')} to overwrite`); - exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR); + // Keep the --force hint in the exitWithError message so it appears in the single output line + exitWithError( + `Profile already exists: ${profileName}\n Use --force to overwrite`, + ExitCode.PROFILE_ERROR + ); } const normalizedName = sanitizeProfileNameForInstance(profileName); @@ -87,10 +87,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise ); if (collidingName) { - const error = `Profile "${profileName}" conflicts with existing profile "${collidingName}" on filesystem.`; - console.log(fail(error)); - console.log(''); - exitWithError(error, ExitCode.PROFILE_ERROR); + exitWithError( + `Profile "${profileName}" conflicts with existing profile "${collidingName}" on filesystem.`, + ExitCode.PROFILE_ERROR + ); } const resolvedContext = resolveCreateAccountContext({ @@ -100,8 +100,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise }); if (resolvedContext.error) { - console.log(fail(resolvedContext.error)); - console.log(''); exitWithError(resolvedContext.error, ExitCode.PROFILE_ERROR); } diff --git a/src/auth/commands/default-command.ts b/src/auth/commands/default-command.ts index 8836aee1..4e3b6bc1 100644 --- a/src/auth/commands/default-command.ts +++ b/src/auth/commands/default-command.ts @@ -23,7 +23,6 @@ export async function handleDefault(ctx: CommandContext, args: string[]): Promis }); if (!profileName) { - console.log(fail('Profile name is required')); console.log(''); console.log(`Usage: ${color('ccs auth default ', 'command')}`); exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); diff --git a/src/auth/commands/remove-command.ts b/src/auth/commands/remove-command.ts index fed45098..60f4bb3d 100644 --- a/src/auth/commands/remove-command.ts +++ b/src/auth/commands/remove-command.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { initUI, color, ok, fail, info } from '../../utils/ui'; +import { initUI, color, ok, info } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; import { exitWithError } from '../../errors'; @@ -26,7 +26,6 @@ export async function handleRemove(ctx: CommandContext, args: string[]): Promise }); if (!profileName) { - console.log(fail('Profile name is required')); console.log(''); console.log(`Usage: ${color('ccs auth remove [--yes]', 'command')}`); exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); @@ -37,7 +36,6 @@ export async function handleRemove(ctx: CommandContext, args: string[]): Promise const existsUnified = ctx.registry.hasAccountUnified(profileName); if (!existsLegacy && !existsUnified) { - console.log(fail(`Profile not found: ${profileName}`)); exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); } diff --git a/src/auth/commands/resources-command.ts b/src/auth/commands/resources-command.ts index cf6666c3..af7e0bf0 100644 --- a/src/auth/commands/resources-command.ts +++ b/src/auth/commands/resources-command.ts @@ -1,4 +1,4 @@ -import { initUI, header, color, fail, ok, table } from '../../utils/ui'; +import { initUI, header, color, ok, table } from '../../utils/ui'; import { resolveAccountContextPolicy } from '../account-context'; import { isSharedResourceMode, @@ -30,7 +30,6 @@ export async function handleResources(ctx: CommandContext, args: string[]): Prom }); if (!profileName) { - console.log(fail('Profile name is required')); console.log(''); console.log( `Usage: ${color('ccs auth resources [--mode shared|profile-local] [--json]', 'command')}` diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts index 37602688..2ae38136 100644 --- a/src/auth/commands/show-command.ts +++ b/src/auth/commands/show-command.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { initUI, header, color, fail, table } from '../../utils/ui'; +import { initUI, header, color, table } from '../../utils/ui'; import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context'; import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics'; import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics'; @@ -33,7 +33,6 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise [--json]', 'command')}`); exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); diff --git a/src/auth/commands/types.ts b/src/auth/commands/types.ts index f091b410..b67d7298 100644 --- a/src/auth/commands/types.ts +++ b/src/auth/commands/types.ts @@ -8,7 +8,7 @@ import ProfileRegistry from '../profile-registry'; import { InstanceManager } from '../../management/instance-manager'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { color, fail } from '../../utils/ui'; +import { color } from '../../utils/ui'; // Re-export for backward compatibility export { formatRelativeTime } from '../../utils/time'; @@ -100,7 +100,6 @@ export function rejectUnsupportedAuthOptions( } const unknownList = unsupportedOptions.map((flag) => `"${flag}"`).join(', '); - console.log(fail(`Unknown option(s): ${unknownList}`)); console.log(''); console.log(`Usage: ${color(options.usage, 'command')}`); console.log(`Help: ${color('ccs auth --help', 'command')}`); diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index b910b298..22c19203 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -376,13 +376,19 @@ export class ProfileRegistry { } /** - * Set default profile in unified config + * Set default profile in unified config. + * Accepts names from unified config (accounts, profiles, cliproxy variants) + * as well as legacy profiles.json so that mixed-mode installs work correctly. */ setDefaultUnified(name: string): void { + // Check legacy registry outside the mutate callback to avoid re-reading inside the transaction. + const legacyData = this._read(); + const existsLegacy = !!legacyData.profiles[name]; + mutateConfig((config) => { - const exists = + const existsUnified = config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name]; - if (!exists) { + if (!existsUnified && !existsLegacy) { throw new ProfileError(`Profile not found: ${name}`, name); } config.default = name; From 9258aed1ff44dbb8d6318c5f0f43fdf077aae08a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:39:55 -0400 Subject: [PATCH 08/29] fix(cli): clearer profile/proxy/api errors and rich not-found suggestions Move resolveProfileAndTarget() inside the try block in ccs.ts so a missing profile shows suggestions/available-profiles instead of a generic error. Use a typed ProfileError for proxy start on an unknown profile (drops the irrelevant install hint). api create: honor --yes for the OpenRouter model browser, show ccs-codex/ccsx aliases for --target codex, and allow ANTHROPIC_EXTRA_MODELS on import. --- .../services/profile-lifecycle-validation.ts | 2 + src/ccs.ts | 54 ++++++++++--------- src/commands/api-command/create-command.ts | 30 ++++++++--- src/commands/proxy-command.ts | 16 +++++- 4 files changed, 68 insertions(+), 34 deletions(-) diff --git a/src/api/services/profile-lifecycle-validation.ts b/src/api/services/profile-lifecycle-validation.ts index 7ff09654..d7902584 100644 --- a/src/api/services/profile-lifecycle-validation.ts +++ b/src/api/services/profile-lifecycle-validation.ts @@ -23,6 +23,8 @@ const ALLOWED_ANTHROPIC_ENV_KEYS = new Set([ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', + // Written by profile-writer when --extra-models is supplied + 'ANTHROPIC_EXTRA_MODELS', ...MODEL_ENV_KEYS, ]); diff --git a/src/ccs.ts b/src/ccs.ts index 28a89ec3..e8fe5996 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -46,31 +46,37 @@ async function main(): Promise { return; } - // Phase C: profile + target detection (extracted to dispatcher/profile-resolver.ts) - const resolvedProfile = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger }); - const { profile, profileInfo, resolvedTarget, nativeClaudeRemainingArgs } = resolvedProfile; - - // Dynamic imports needed by Phase E flows — preserve original load ordering. - const InstanceManagerModule = await import('./management/instance-manager'); - const InstanceManager = InstanceManagerModule.default; - const ProfileRegistryModule = await import('./auth/profile-registry'); - const ProfileRegistry = ProfileRegistryModule.default; - const AccountContextModule = await import('./auth/account-context'); - const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule; - const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); - const { resolveProfileContinuityInheritance } = ProfileContinuityModule; - - // Build full dispatch context (Phase E) - const dispatchCtx = { - ...resolvedProfile, - InstanceManager, - ProfileRegistry, - resolveAccountContextPolicy, - isAccountContextMetadata, - resolveProfileContinuityInheritance, - }; - try { + // Phase C: profile + target detection — inside try so ProfileNotFoundError + // reaches the rich showProfileNotFound() handler below instead of falling + // through to the bare unhandledRejection handler. + const resolvedProfile = await resolveProfileAndTarget({ + args, + browserLaunchOverride, + cliLogger, + }); + const { profile, profileInfo, resolvedTarget, nativeClaudeRemainingArgs } = resolvedProfile; + + // Dynamic imports needed by Phase E flows — preserve original load ordering. + const InstanceManagerModule = await import('./management/instance-manager'); + const InstanceManager = InstanceManagerModule.default; + const ProfileRegistryModule = await import('./auth/profile-registry'); + const ProfileRegistry = ProfileRegistryModule.default; + const AccountContextModule = await import('./auth/account-context'); + const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule; + const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); + const { resolveProfileContinuityInheritance } = ProfileContinuityModule; + + // Build full dispatch context (Phase E) + const dispatchCtx = { + ...resolvedProfile, + InstanceManager, + ProfileRegistry, + resolveAccountContextPolicy, + isAccountContextMetadata, + resolveProfileContinuityInheritance, + }; + // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue // through normal adapter dispatch logic. diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index b0d799f8..77b585f3 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -196,14 +196,17 @@ async function resolveModelConfiguration( if (isOpenRouterUrl(baseUrl) && !providedModel) { console.log(''); console.log(info('OpenRouter detected!')); - const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', { - default: true, - }); - if (useInteractive) { - const selection = await pickOpenRouterModel(); - if (selection) { - openRouterModel = selection.model; - openRouterTierMapping = selection.tierMapping; + // --yes/-y suppresses interactive model browser; use preset defaultModel instead + if (!yes) { + const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', { + default: true, + }); + if (useInteractive) { + const selection = await pickOpenRouterModel(); + if (selection) { + openRouterModel = selection.model; + openRouterTierMapping = selection.tierMapping; + } } } console.log(''); @@ -571,6 +574,17 @@ export async function handleApiCreateCommand(args: string[]): Promise { console.log( ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` ); + } else if (target === 'codex') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses Codex CLI by default')}` + ); + console.log( + ` ${color(`ccs-codex ${name} "your prompt"`, 'command')} ${dim('# explicit Codex alias')}` + ); + console.log(` ${color(`ccsx ${name} "your prompt"`, 'command')} ${dim('# short alias')}`); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); } else { console.log( ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 53a11b11..ba066702 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -10,7 +10,8 @@ import { startOpenAICompatProxy, stopOpenAICompatProxy, } from '../proxy'; -import { loadSettings } from '../config/config-loader-facade'; +import { loadConfigSafe, loadSettings } from '../config/config-loader-facade'; +import { ProfileError } from '../errors'; function parseOptionValue(args: string[], key: string): string | undefined { const exactIndex = args.findIndex((arg) => arg === key); @@ -82,11 +83,22 @@ function showHelp(): number { } function resolveProfile(profileName: string) { + // Pre-check profile in config to avoid the generic error() + npm-install hint from getSettingsPath. + const config = loadConfigSafe(); + if (!config.profiles[profileName]) { + throw new ProfileError( + `Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`, + profileName + ); + } const settingsPath = expandPath(getSettingsPath(profileName)); const settings = loadSettings(settingsPath); const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, settings.env || {}); if (!profile) { - throw new Error(`Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`); + throw new ProfileError( + `Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`, + profileName + ); } return profile; } From 29007437677183d856d0081a16d28461c7f4da4a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH 09/29] fix(dispatch): route unknown bar/copilot/cursor subcommands and survive corrupt config Pre-dispatch only routed known subcommand tokens, so unknown bar/copilot/cursor subcommands never reached their handlers (no error, no non-zero exit). Route them through. Also wrap autoMigrate() so a corrupt config.yaml no longer crashes startup, and print only the first line of a best-effort recovery failure. --- src/commands/bar/index.ts | 1 + src/dispatcher/pre-dispatch.ts | 42 ++++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/commands/bar/index.ts b/src/commands/bar/index.ts index 27ecb28b..f12d8cd4 100644 --- a/src/commands/bar/index.ts +++ b/src/commands/bar/index.ts @@ -66,6 +66,7 @@ export async function handleBarCommand(args: string[]): Promise { if (!handler) { console.error(`[X] Unknown bar subcommand: ${subcommand}`); console.error('[i] Usage: ccs bar [launch|serve|stop|status|install|uninstall|version|--help]'); + process.exitCode = 1; return; } diff --git a/src/dispatcher/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts index aef943c6..e5c0d4eb 100644 --- a/src/dispatcher/pre-dispatch.ts +++ b/src/dispatcher/pre-dispatch.ts @@ -68,10 +68,19 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise" and exit 1. + // Unknown tokens that are NOT subcommand-like are kept as profile passthrough. if (firstArg === 'copilot' && args.length > 1) { const copilotToken = args[1]; - const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken); + const isKnownSubcommand = isCopilotSubcommandToken(copilotToken); + // Route any non-flag, non-profile-looking second token to the handler so + // the default branch fires with the correct error message. + const looksLikeCopilotSubcmd = + isKnownSubcommand || (typeof copilotToken === 'string' && !copilotToken.startsWith('-')); - if (shouldRouteToCopilotCommand) { + if (looksLikeCopilotSubcmd) { const { handleCopilotCommand } = await import('../commands/copilot-command'); const exitCode = await handleCopilotCommand(args.slice(1)); process.exit(exitCode); @@ -125,14 +142,11 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise 1) { const { handleCursorCommand } = await import('../commands/cursor-command'); - const cursorToken = args[1]; - - if (isCursorSubcommandToken(cursorToken)) { - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); } // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge From 8cfd13ba96956532babbc171c9d9f997401620b0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH 10/29] fix(doctor): keep diagnostics running on corrupt config or unreadable auth loadUnifiedConfig() printed the YAML error on every call (3-4x per run); print it once per path per process. Run each doctor check group defensively so a thrown check (corrupt config, EACCES auth dir) records an error and the SUMMARY/ERRORS sections with recovery hints still render instead of crashing mid-run. Trim embedded parser snippets to one line so they no longer leak into the summary table or repeat across checks. --- src/commands/setup-command.ts | 11 ++++++- src/config/unified-config-loader.ts | 41 ++++++++++++++++--------- src/management/checks/cliproxy-check.ts | 21 ++++++++++++- src/management/checks/config-check.ts | 5 ++- src/management/checks/profile-check.ts | 40 +++++++++--------------- src/management/doctor.ts | 40 ++++++++++++++++++------ 6 files changed, 105 insertions(+), 53 deletions(-) diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index d5cccc2f..60e23b83 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -128,7 +128,16 @@ async function selectOption( export function isFirstTimeInstall(): boolean { // Check unified config first (config.yaml) if (hasUnifiedConfig()) { - const loaded = loadUnifiedConfig(); + let loaded: ReturnType; + try { + loaded = loadUnifiedConfig(); + } catch { + // Config exists but is corrupted/unparseable — treat as not first-time so we + // don't run the setup wizard; the YAML error was already printed by loadUnifiedConfig. + 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; + } // Config exists but is corrupted/invalid - don't treat as first-time if (loaded === null) { diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 59fe94ea..dbd3cb3c 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -25,6 +25,12 @@ import { import type { UnifiedConfig } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; +// loadUnifiedConfig() runs several times per invocation (startup migration check, +// first-time-install check, command handler, doctor checks). A corrupt config.yaml +// must report its parse error ONCE, not once per call. Track paths already warned +// so repeated loads in the same process stay quiet. +const warnedCorruptConfigPaths = new Set(); + // --------------------------------------------------------------------------- // Phase 1 re-exports: io-locks // --------------------------------------------------------------------------- @@ -167,22 +173,27 @@ export function loadUnifiedConfig(): UnifiedConfig | null { return parsed; } catch (err) { - // U3: Provide better context for YAML syntax errors - if (err instanceof yaml.YAMLException) { - const mark = err.mark; - console.error(`[X] YAML syntax error in ${yamlPath}:`); - console.error( - ` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}` - ); - if (mark?.snippet) { - console.error(` ${mark.snippet}`); + // U3: Provide better context for YAML syntax errors. Print at most once per + // path per process so repeated loads of a corrupt file do not spam the error. + const alreadyWarned = warnedCorruptConfigPaths.has(yamlPath); + if (!alreadyWarned) { + warnedCorruptConfigPaths.add(yamlPath); + if (err instanceof yaml.YAMLException) { + const mark = err.mark; + console.error(`[X] YAML syntax error in ${yamlPath}:`); + console.error( + ` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}` + ); + if (mark?.snippet) { + console.error(` ${mark.snippet}`); + } + console.error( + ` Tip: Check for missing colons, incorrect indentation, or unquoted special characters.` + ); + } else { + const error = err instanceof Error ? err.message : 'Unknown error'; + console.error(`[X] Failed to load config: ${error}`); } - console.error( - ` Tip: Check for missing colons, incorrect indentation, or unquoted special characters.` - ); - } else { - const error = err instanceof Error ? err.message : 'Unknown error'; - console.error(`[X] Failed to load config: ${error}`); } throw err; } diff --git a/src/management/checks/cliproxy-check.ts b/src/management/checks/cliproxy-check.ts index 4a4998b6..eb85d73e 100644 --- a/src/management/checks/cliproxy-check.ts +++ b/src/management/checks/cliproxy-check.ts @@ -108,7 +108,26 @@ export class CLIProxyAuthChecker implements IHealthChecker { name = 'CLIProxy Auth'; run(results: HealthCheck): void { - const authStatuses = getAllAuthStatus(); + // Reading auth status touches ~/.ccs; a permission error (EACCES) must not + // crash doctor before the ERRORS section renders. Surface it as a recoverable + // check instead of throwing. + let authStatuses; + try { + authStatuses = getAllAuthStatus(); + } catch (err) { + const spinner = ora('Checking CLIProxy auth').start(); + spinner.fail(); + const message = err instanceof Error ? err.message : String(err); + console.log(` ${warn('CLIProxy Auth'.padEnd(22))} Unable to read auth status`); + results.addCheck( + 'CLIProxy Auth', + 'error', + `Unable to read CLIProxy auth status: ${message}`, + 'sudo chown -R $USER ~/.ccs ~/.claude && chmod -R u+rwX ~/.ccs ~/.claude', + { status: 'ERROR', info: 'Auth status unavailable (permission or read error)' } + ); + return; + } for (const status of authStatuses) { const spinner = ora(`Checking ${status.provider} auth`).start(); const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts index f3ac17e0..beee75e7 100644 --- a/src/management/checks/config-check.ts +++ b/src/management/checks/config-check.ts @@ -72,10 +72,13 @@ export class ConfigFilesChecker implements IHealthChecker { } catch (e) { spinner.fail(); console.log(` ${fail('config.yaml'.padEnd(22))} Invalid YAML`); + // First line only: the parser message embeds a multi-line snippet the + // loader already printed, so keep the ERRORS entry to a single line. + const reason = (e as Error).message.split('\n')[0].trim(); results.addCheck( 'config.yaml', 'error', - `Invalid YAML: ${(e as Error).message}`, + `Invalid YAML: ${reason}`, `Backup and recreate: mv ${configYamlPath} ${configYamlPath}.backup && npm install -g @kaitranntt/ccs --force`, { status: 'ERROR', info: 'Invalid YAML' } ); diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts index dd55ecee..e1717f39 100644 --- a/src/management/checks/profile-check.ts +++ b/src/management/checks/profile-check.ts @@ -40,19 +40,14 @@ export class ProfilesChecker implements IHealthChecker { return; } catch (e) { spinner.fail(); - console.log( - ` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${(e as Error).message}` - ); - results.addCheck( - 'Profiles', - 'error', - `Invalid config.yaml: ${(e as Error).message}`, - undefined, - { - status: 'ERROR', - info: (e as Error).message, - } - ); + // First line only: a YAML parse error embeds a multi-line snippet (already + // printed by the loader) that otherwise leaks into the summary table cell. + const reason = (e as Error).message.split('\n')[0].trim(); + console.log(` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${reason}`); + results.addCheck('Profiles', 'error', `Invalid config.yaml: ${reason}`, undefined, { + status: 'ERROR', + info: reason, + }); return; } } @@ -65,19 +60,12 @@ export class ProfilesChecker implements IHealthChecker { return; } catch (e) { spinner.fail(); - console.log( - ` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${(e as Error).message}` - ); - results.addCheck( - 'Profiles', - 'error', - `Invalid config.json: ${(e as Error).message}`, - undefined, - { - status: 'ERROR', - info: (e as Error).message, - } - ); + const reason = (e as Error).message.split('\n')[0].trim(); + console.log(` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${reason}`); + results.addCheck('Profiles', 'error', `Invalid config.json: ${reason}`, undefined, { + status: 'ERROR', + info: reason, + }); return; } } diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 13d0fa3a..458cdb8d 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -46,49 +46,71 @@ class Doctor { // Group 1: System console.log(header('SYSTEM')); - await runSystemChecks(this.results); + await this.safe('System Checks', () => runSystemChecks(this.results)); console.log(''); // Group 2: Environment (OAuth readiness diagnostics) console.log(header('ENVIRONMENT')); - runEnvironmentCheck(this.results); + await this.safe('Environment Check', () => runEnvironmentCheck(this.results)); console.log(''); // Group 3: Configuration console.log(header('CONFIGURATION')); - runConfigChecks(this.results); - this.runDockerKeyRotationCheck(); + await this.safe('Configuration Checks', () => runConfigChecks(this.results)); + await this.safe('Docker Key Rotation', () => this.runDockerKeyRotationCheck()); console.log(''); // Group 4: Profiles & Delegation console.log(header('PROFILES & DELEGATION')); - runProfileChecks(this.results); + await this.safe('Profile Checks', () => runProfileChecks(this.results)); console.log(''); // Group 5: System Health console.log(header('SYSTEM HEALTH')); - runSymlinkChecks(this.results); + await this.safe('Symlink Checks', () => runSymlinkChecks(this.results)); console.log(''); // Group 6: CLIProxy Plus (OAuth profiles) console.log(header('CLIPROXY PLUS (OAUTH PROFILES)')); - await runCLIProxyChecks(this.results); + await this.safe('CLIProxy Checks', () => runCLIProxyChecks(this.results)); console.log(''); // Group 7: OAuth Readiness (port availability) console.log(header('OAUTH READINESS')); - await runOAuthChecks(this.results); + await this.safe('OAuth Checks', () => runOAuthChecks(this.results)); console.log(''); // Group 8: Image Analysis Config console.log(header('IMAGE ANALYSIS')); - await runImageAnalysisCheck(this.results); + await this.safe('Image Analysis Check', () => runImageAnalysisCheck(this.results)); console.log(''); this.showReport(); return this.results; } + /** + * Run one check group defensively. A thrown check (e.g. a corrupt config.yaml + * that makes a loader throw) is recorded as an error instead of aborting the + * whole run, so the SUMMARY and ERRORS sections (with recovery hints) always + * render. + */ + private async safe(label: string, fn: () => void | Promise): Promise { + try { + await fn(); + } catch (err) { + // Use only the first line of the message: parser errors (e.g. js-yaml) + // embed a multi-line snippet that the loader already printed once, so the + // ERRORS section stays one line per failed check. + const raw = err instanceof Error ? err.message : String(err); + const message = raw.split('\n')[0].trim(); + this.results.addCheck(label, 'error', message, undefined, { + status: 'ERROR', + info: 'check failed (see ERRORS)', + }); + } + } + /** * Show health check report */ From 9d5b33382b36e5c8ce4cc5f77f7c9e4264eeca86 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH 11/29] fix(cliproxy): gate binary download log, fix pool message prefix and routing affinity help Only log 'Binary not found, downloading...' when a download will actually happen (after the allowInstall guard). Stop double-prefixing pool enable/disable messages that already carry [OK]/[!]. Route 'cliproxy routing affinity --help' to affinity help instead of the generic cliproxy help. --- src/cliproxy/binary/lifecycle.ts | 7 ++++--- src/commands/cliproxy/index.ts | 11 ++++++++--- src/commands/cliproxy/pool-subcommand.ts | 6 ++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 474a3857..f16b103d 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -132,9 +132,7 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise return binaryPath; } - // Binary missing - download - log('Binary not found, downloading...', verbose); - + // Binary missing if (!config.allowInstall) { throw new Error( `${getBackendLabel(backend)} binary is not installed locally. ` + @@ -142,6 +140,9 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise ); } + // Only log download intent when a download will actually happen + log('Binary not found, downloading...', verbose); + if (!config.forceVersion) { try { const latestVersion = await fetchLatestVersion(verbose, backend); diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index b580fdda..b0595c62 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -146,9 +146,14 @@ export async function handleCliproxyCommand(args: string[]): Promise { const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']); const command = remainingArgs[0]; - if (hasAnyFlag(remainingArgs, ['--help', '-h'])) { - await showHelp(); - return; + // Show global cliproxy help only when --help/-h is the top-level intent (no + // subcommand, or the flag is the first arg). Subcommands that accept --help + // (e.g. routing affinity --help) handle it themselves after dispatch. + if (!command || command === '--help' || command === '-h') { + if (hasAnyFlag(remainingArgs, ['--help', '-h'])) { + await showHelp(); + return; + } } // Catalog commands diff --git a/src/commands/cliproxy/pool-subcommand.ts b/src/commands/cliproxy/pool-subcommand.ts index a2758bbe..c19cb269 100644 --- a/src/commands/cliproxy/pool-subcommand.ts +++ b/src/commands/cliproxy/pool-subcommand.ts @@ -79,7 +79,8 @@ export async function handlePoolSubcommand(args: string[]): Promise { console.log(warn(result.message)); process.exitCode = 1; } else if (result.changed) { - console.log(ok(result.message)); + // message already carries the correct [OK]/[!] prefix from routing-strategy + console.log(result.message); } else { console.log(info(result.message)); } @@ -93,7 +94,8 @@ export async function handlePoolSubcommand(args: string[]): Promise { console.log(warn(result.message)); process.exitCode = 1; } else if (result.changed) { - console.log(ok(result.message)); + // message already carries the correct [OK]/[!] prefix from routing-strategy + console.log(result.message); } else { console.log(info(result.message)); } From e4c91d285330ba73b45f78c159355d8bae996a0a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH 12/29] fix(persist): accurate symlink guard message on backup read The guard fires on the backup READ step; use a typed ConfigError whose message matches the actual step (refusing to read), not a misleading 'refusing to write'. --- src/commands/persist-command/backup-rotation.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/commands/persist-command/backup-rotation.ts b/src/commands/persist-command/backup-rotation.ts index deba4449..2561ff0a 100644 --- a/src/commands/persist-command/backup-rotation.ts +++ b/src/commands/persist-command/backup-rotation.ts @@ -11,6 +11,7 @@ import * as path from 'path'; import { initUI, header, color, dim, ok, fail, warn, info } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; import { getClaudeSettingsPath } from '../../utils/claude-config-path'; +import { ConfigError } from '../../errors'; import { formatDisplayPath, getClaudeSettingsDisplayPath, @@ -86,6 +87,14 @@ export async function createBackup(): Promise { throw new Error('No settings.json to backup'); } + // Guard: refuse to read a symlinked settings.json (TOCTOU mitigation on backup read). + if (await isSymlinkAsync(settingsPath)) { + throw new ConfigError( + 'settings.json is a symlink - refusing to read for backup for security', + settingsPath + ); + } + const settingsContent = await readFileUtf8NoFollow(settingsPath); const now = new Date(); From 016c47ec9acb1db4b8b9a4aec4a123be869ffee8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH 13/29] chore(lint): refresh throw-error baseline for shifted and converted throws --- eslint-rules/throw-error-baseline.json | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index 79ab7670..a30cb176 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -39,7 +39,7 @@ "src/cliproxy/binary/installer.ts:55", "src/cliproxy/binary/installer.ts:74", "src/cliproxy/binary/installer.ts:88", - "src/cliproxy/binary/lifecycle.ts:139", + "src/cliproxy/binary/lifecycle.ts:137", "src/cliproxy/binary/platform-detector.ts:153", "src/cliproxy/binary/platform-detector.ts:160", "src/cliproxy/binary/verifier.ts:55", @@ -62,7 +62,6 @@ "src/cliproxy/executor/proxy-resolver.ts:163", "src/cliproxy/executor/proxy-resolver.ts:177", "src/cliproxy/executor/session-bridge.ts:147", - "src/cliproxy/provider-capabilities.ts:257", "src/cliproxy/proxy/https-tunnel-proxy.ts:61", "src/cliproxy/routing/routing-strategy.ts:480", "src/cliproxy/routing/routing-strategy.ts:488", @@ -121,10 +120,10 @@ "src/commands/config-channels-command.ts:436", "src/commands/config-channels-command.ts:447", "src/commands/persist-command/arg-parsing.ts:31", - "src/commands/persist-command/backup-rotation.ts:226", - "src/commands/persist-command/backup-rotation.ts:244", - "src/commands/persist-command/backup-rotation.ts:249", - "src/commands/persist-command/backup-rotation.ts:86", + "src/commands/persist-command/backup-rotation.ts:235", + "src/commands/persist-command/backup-rotation.ts:253", + "src/commands/persist-command/backup-rotation.ts:258", + "src/commands/persist-command/backup-rotation.ts:87", "src/commands/persist-command/handler.ts:166", "src/commands/persist-command/handler.ts:38", "src/commands/persist-command/secure-file.ts:104", @@ -134,14 +133,13 @@ "src/commands/persist-command/secure-file.ts:174", "src/commands/persist-command/secure-file.ts:182", "src/commands/persist-command/secure-file.ts:98", - "src/commands/proxy-command.ts:89", - "src/commands/setup-command.ts:218", + "src/commands/setup-command.ts:227", "src/config/loader/io-locks.ts:205", "src/config/loader/io-locks.ts:241", "src/config/loader/io-locks.ts:295", "src/config/loader/io-locks.ts:297", "src/config/reserved-names.ts:92", - "src/config/unified-config-loader.ts:149", + "src/config/unified-config-loader.ts:155", "src/copilot/copilot-package-manager.ts:444", "src/copilot/copilot-package-manager.ts:467", "src/cursor/cursor-anthropic-translator.ts:113", From a5feefe45a62044bbfede28edaaa175f20795399 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 12:05:34 -0400 Subject: [PATCH 14/29] fix(cliproxy): honor status --verbose and document --provider/diag in help handleProxyStatus now respects --verbose (was parsed but ignored). Document the existing --provider option on default/pause/resume and the 'diag' alias for doctor in the cliproxy help text. --- src/commands/cliproxy/help-subcommand.ts | 10 +++++----- src/commands/cliproxy/index.ts | 2 +- .../cliproxy/proxy-lifecycle-subcommand.ts | 15 ++++++++++++++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 27e61e14..4049a616 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -53,9 +53,9 @@ export async function showHelp(): Promise { [ 'Quota Management:', [ - ['default ', 'Set default account for rotation'], - ['pause ', 'Pause account (skip in rotation)'], - ['resume ', 'Resume paused account'], + ['default [--provider ]', 'Set default account for rotation'], + ['pause [--provider ]', 'Pause account (skip in rotation)'], + ['resume [--provider ]', 'Resume paused account'], [ 'quota', 'Show quota status + pool context (drain order, per-account available/cooling/paused)', @@ -89,9 +89,9 @@ export async function showHelp(): Promise { [ ['start', 'Start CLIProxy instance in background'], ['restart', 'Restart CLIProxy instance'], - ['status', 'Show running CLIProxy status'], + ['status [--verbose]', 'Show running CLIProxy status (--verbose adds uptime)'], ['stop', 'Stop running CLIProxy instance'], - ['doctor', 'Quota diagnostics and shared project detection'], + ['doctor | diag', 'Quota diagnostics and shared project detection'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index b0595c62..e518f3ee 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -250,7 +250,7 @@ export async function handleCliproxyCommand(args: string[]): Promise { start: async () => handleStart(verbose), stop: async () => handleStop(), restart: async () => handleRestart(verbose), - status: async () => handleProxyStatus(), + status: async () => handleProxyStatus(verbose), doctor: async () => handleDoctor(verbose), diag: async () => handleDoctor(verbose), default: async () => handleSetDefault(remainingArgs.slice(1)), diff --git a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts index 001e995f..7c4e4462 100644 --- a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts +++ b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts @@ -66,7 +66,7 @@ export async function handleRestart(verbose = false): Promise { console.log(''); } -export async function handleProxyStatus(): Promise { +export async function handleProxyStatus(verbose = false): Promise { await initUI(); console.log(header('CLIProxy Status')); console.log(''); @@ -80,7 +80,17 @@ export async function handleProxyStatus(): Promise { console.log(` Sessions: ${status.sessionCount || 0} active`); if (status.startedAt) { console.log(` Started: ${new Date(status.startedAt).toLocaleString()}`); + if (verbose) { + const uptimeMs = Date.now() - new Date(status.startedAt).getTime(); + const uptimeSec = Math.floor(uptimeMs / 1000); + const h = Math.floor(uptimeSec / 3600); + const m = Math.floor((uptimeSec % 3600) / 60); + const s = uptimeSec % 60; + const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`; + console.log(` Uptime: ${uptimeStr}`); + } } + console.log(''); console.log(dim('To stop: ccs cliproxy stop')); } else { @@ -94,6 +104,9 @@ export async function handleProxyStatus(): Promise { if (!detected.sessionCount) { console.log(dim(' Note: Detected running proxy without local session lock')); } + if (verbose) { + console.log(dim(' Note: Process detected via port scan; no local lock file present')); + } console.log(''); console.log(dim('To stop: ccs cliproxy stop')); } else { From 5e4a90b3e5694af0678d9f34b5e9c25c601a9a89 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 12:05:34 -0400 Subject: [PATCH 15/29] fix(cli): warn on tokens --variant without --api-key; list bar serve in help 'ccs tokens --variant ' without --api-key was a silent no-op; now it explains that --variant only applies with --api-key. Add the existing 'serve' subcommand to the bar help Commands list. --- src/commands/bar/help-subcommand.ts | 1 + src/commands/tokens-command.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/commands/bar/help-subcommand.ts b/src/commands/bar/help-subcommand.ts index 0511a1ba..95b63095 100644 --- a/src/commands/bar/help-subcommand.ts +++ b/src/commands/bar/help-subcommand.ts @@ -14,6 +14,7 @@ export async function showHelp(): Promise { 'Commands:', [ ['launch', 'Spawn the server detached, write ~/.ccs/bar.json, open the app (default)'], + ['serve', 'Run the CCS Bar server in the foreground (used internally by launch)'], ['stop', 'Stop the detached CCS Bar server'], ['status', 'Show whether the CCS Bar server is running'], ['install', 'Download CCS Bar from the ccs-bar-latest GitHub release into ~/Applications'], diff --git a/src/commands/tokens-command.ts b/src/commands/tokens-command.ts index 7d3f06cf..0dfe92c9 100644 --- a/src/commands/tokens-command.ts +++ b/src/commands/tokens-command.ts @@ -141,6 +141,13 @@ export async function handleTokensCommand(args: string[]): Promise { return 1; } + // --variant without --api-key is a misuse — it has no effect without a key to assign. + if (variantValue !== undefined && !hasApiKeyFlag) { + console.error(fail('--variant requires --api-key')); + console.error(info('Usage: ccs tokens --variant --api-key ')); + return 1; + } + let updated = false; if (regenerateSecretFlag) { From 40d7617cc1cc316671f839c1af26be47fa189cc3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 12:05:34 -0400 Subject: [PATCH 16/29] fix(auth): re-export interface-only barrel symbols as types for bun dev runs The auth commands barrel re-exported 4 interface-only symbols (AuthCommandArgs, ProfileOutput, ListOutput, CommandContext) via a value 'export {}', which bun's per-file transpile cannot resolve at runtime (the tsc dist elides them, so users were unaffected). Split them into 'export type {}' so 'bun run dev' / source runs work. --- src/auth/commands/index.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/auth/commands/index.ts b/src/auth/commands/index.ts index e21869d5..571ee721 100644 --- a/src/auth/commands/index.ts +++ b/src/auth/commands/index.ts @@ -5,14 +5,8 @@ */ // Types and utilities -export { - AuthCommandArgs, - ProfileOutput, - ListOutput, - CommandContext, - parseArgs, - formatRelativeTime, -} from './types'; +export type { AuthCommandArgs, ProfileOutput, ListOutput, CommandContext } from './types'; +export { parseArgs, formatRelativeTime } from './types'; // Command handlers export { handleCreate } from './create-command'; From 310f4f6e522086090d9634812a94e9abde9d17a3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 12:18:50 -0400 Subject: [PATCH 17/29] fix(cliproxy): show global help regardless of flag order before --help Top-level cliproxy help was order-sensitive: 'cliproxy --verbose --help' fell through to dispatch instead of showing help. Fire global help whenever there is no real subcommand (no args or a leading flag) and --help/-h appears anywhere; real subcommands still handle their own --help. --- src/commands/cliproxy/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index e518f3ee..6382edda 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -146,14 +146,14 @@ export async function handleCliproxyCommand(args: string[]): Promise { const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']); const command = remainingArgs[0]; - // Show global cliproxy help only when --help/-h is the top-level intent (no - // subcommand, or the flag is the first arg). Subcommands that accept --help - // (e.g. routing affinity --help) handle it themselves after dispatch. - if (!command || command === '--help' || command === '-h') { - if (hasAnyFlag(remainingArgs, ['--help', '-h'])) { - await showHelp(); - return; - } + // Show global cliproxy help when there is no real subcommand (no args, or the + // first token is a flag) and --help/-h appears anywhere — order-insensitive, so + // `cliproxy --verbose --help` still works. Real subcommands (e.g. + // `routing affinity --help`) handle --help themselves after dispatch. + const hasRealSubcommand = typeof command === 'string' && !command.startsWith('-'); + if (!hasRealSubcommand && hasAnyFlag(remainingArgs, ['--help', '-h'])) { + await showHelp(); + return; } // Catalog commands From d8cd2f94b58ad0d6799e81cac42384b851618c3a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 13:10:59 -0400 Subject: [PATCH 18/29] fix(cliproxy): keep --help working for all subcommands The earlier help-gating overcorrected: scoping global help to 'no real subcommand' meant 'cliproxy status --help' (and list/quota) ran the command and ignored --help. Restore global help on --help/-h anywhere (order-insensitive), carving out only 'routing affinity --help', which renders its own help. --- src/commands/cliproxy/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 6382edda..8acaea13 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -146,12 +146,12 @@ export async function handleCliproxyCommand(args: string[]): Promise { const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']); const command = remainingArgs[0]; - // Show global cliproxy help when there is no real subcommand (no args, or the - // first token is a flag) and --help/-h appears anywhere — order-insensitive, so - // `cliproxy --verbose --help` still works. Real subcommands (e.g. - // `routing affinity --help`) handle --help themselves after dispatch. - const hasRealSubcommand = typeof command === 'string' && !command.startsWith('-'); - if (!hasRealSubcommand && hasAnyFlag(remainingArgs, ['--help', '-h'])) { + // Show global cliproxy help whenever --help/-h appears (order-insensitive, so + // `cliproxy status --help` and `cliproxy --verbose --help` both work). The one + // exception is `routing affinity --help`, which renders affinity-specific help + // after dispatch (handled below). + const isRoutingAffinity = command === 'routing' && remainingArgs[1] === 'affinity'; + if (!isRoutingAffinity && hasAnyFlag(remainingArgs, ['--help', '-h'])) { await showHelp(); return; } From 819d43914a7a85e14afde0f117b1f56d61a11cab Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 13:10:59 -0400 Subject: [PATCH 19/29] fix(dispatch): restore copilot and legacy-cursor arg passthrough Routing every non-flag token after 'copilot'/'legacy-cursor' to the subcommand handler turned the legacy profile/prompt passthrough into an 'Unknown subcommand' error. Route only known subcommands (matching prior behavior); other args pass through to the copilot/cursor bridge flow. Bar keeps its unknown-subcommand exit code (it has no passthrough). --- src/dispatcher/pre-dispatch.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/dispatcher/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts index e5c0d4eb..32b4c897 100644 --- a/src/dispatcher/pre-dispatch.ts +++ b/src/dispatcher/pre-dispatch.ts @@ -122,19 +122,14 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise" and exit 1. - // Unknown tokens that are NOT subcommand-like are kept as profile passthrough. + // Special case: copilot command (GitHub Copilot integration). + // Route ONLY known subcommands to the handler; any other arg is kept as + // passthrough to the copilot bridge profile flow (legacy behavior). Do not + // treat an arbitrary token as an unknown subcommand error, or this breaks the + // passthrough path. if (firstArg === 'copilot' && args.length > 1) { const copilotToken = args[1]; - const isKnownSubcommand = isCopilotSubcommandToken(copilotToken); - // Route any non-flag, non-profile-looking second token to the handler so - // the default branch fires with the correct error message. - const looksLikeCopilotSubcmd = - isKnownSubcommand || (typeof copilotToken === 'string' && !copilotToken.startsWith('-')); - - if (looksLikeCopilotSubcmd) { + if (isCopilotSubcommandToken(copilotToken)) { const { handleCopilotCommand } = await import('../commands/copilot-command'); const exitCode = await handleCopilotCommand(args.slice(1)); process.exit(exitCode); @@ -142,11 +137,15 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise 1) { - const { handleCursorCommand } = await import('../commands/cursor-command'); - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); + const cursorToken = args[1]; + if (isCursorSubcommandToken(cursorToken)) { + const { handleCursorCommand } = await import('../commands/cursor-command'); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } } // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge From a5149b39e99a40bf13ecbabfb048225dad44f0b1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Jun 2026 17:43:36 +0000 Subject: [PATCH 20/29] chore(release): 8.5.0-dev.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7fc9892a..aff2d97f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.5.0", + "version": "8.5.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From ca7757b712c9bbcfe1feae98181fd5f677ec42e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Jun 2026 20:15:34 +0000 Subject: [PATCH 21/29] chore(release): 8.5.0-dev.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aff2d97f..30a40cfb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.5.0-dev.1", + "version": "8.5.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 6041c5ee73e7e5cc1937f87b9a5e47707e677754 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 16:27:43 -0400 Subject: [PATCH 22/29] chore(lint): regenerate throw-error baseline after request-transformer.ts line shift --- eslint-rules/throw-error-baseline.json | 66 +++++++++++--------------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index a30cb176..57d9457e 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -4,8 +4,6 @@ "src/api/services/profile-lifecycle-service.ts:127", "src/api/services/profile-lifecycle-service.ts:73", "src/api/services/profile-lifecycle-service.ts:88", - "src/api/services/profile-writer.ts:364", - "src/api/services/profile-writer.ts:415", "src/bin/ccsxp-runtime.ts:90", "src/channels/official-channels-store.ts:291", "src/channels/official-channels-store.ts:296", @@ -48,8 +46,6 @@ "src/cliproxy/config/base-config-loader.ts:66", "src/cliproxy/config/base-config-loader.ts:81", "src/cliproxy/config/base-config-loader.ts:91", - "src/cliproxy/config/env-builder.ts:1005", - "src/cliproxy/config/env-builder.ts:700", "src/cliproxy/executor/auth-coordinator.ts:176", "src/cliproxy/executor/auth-coordinator.ts:317", "src/cliproxy/executor/browser-launch-setup.ts:120", @@ -190,35 +186,35 @@ "src/proxy/proxy-daemon-entry.ts:78", "src/proxy/proxy-daemon-entry.ts:88", "src/proxy/proxy-daemon.ts:98", - "src/proxy/transformers/request-transformer.ts:141", - "src/proxy/transformers/request-transformer.ts:182", - "src/proxy/transformers/request-transformer.ts:189", - "src/proxy/transformers/request-transformer.ts:242", - "src/proxy/transformers/request-transformer.ts:259", - "src/proxy/transformers/request-transformer.ts:278", - "src/proxy/transformers/request-transformer.ts:344", - "src/proxy/transformers/request-transformer.ts:360", - "src/proxy/transformers/request-transformer.ts:370", - "src/proxy/transformers/request-transformer.ts:390", - "src/proxy/transformers/request-transformer.ts:428", - "src/proxy/transformers/request-transformer.ts:439", - "src/proxy/transformers/request-transformer.ts:443", - "src/proxy/transformers/request-transformer.ts:459", - "src/proxy/transformers/request-transformer.ts:468", - "src/proxy/transformers/request-transformer.ts:487", - "src/proxy/transformers/request-transformer.ts:493", - "src/proxy/transformers/request-transformer.ts:528", - "src/proxy/transformers/request-transformer.ts:533", - "src/proxy/transformers/request-transformer.ts:538", - "src/proxy/transformers/request-transformer.ts:543", - "src/proxy/transformers/request-transformer.ts:561", - "src/proxy/transformers/request-transformer.ts:566", - "src/proxy/transformers/request-transformer.ts:573", - "src/proxy/transformers/request-transformer.ts:582", - "src/proxy/transformers/request-transformer.ts:633", - "src/proxy/transformers/request-transformer.ts:639", - "src/proxy/transformers/request-transformer.ts:644", - "src/proxy/transformers/request-transformer.ts:665", + "src/proxy/transformers/request-transformer.ts:144", + "src/proxy/transformers/request-transformer.ts:185", + "src/proxy/transformers/request-transformer.ts:192", + "src/proxy/transformers/request-transformer.ts:245", + "src/proxy/transformers/request-transformer.ts:262", + "src/proxy/transformers/request-transformer.ts:281", + "src/proxy/transformers/request-transformer.ts:347", + "src/proxy/transformers/request-transformer.ts:363", + "src/proxy/transformers/request-transformer.ts:373", + "src/proxy/transformers/request-transformer.ts:393", + "src/proxy/transformers/request-transformer.ts:431", + "src/proxy/transformers/request-transformer.ts:442", + "src/proxy/transformers/request-transformer.ts:446", + "src/proxy/transformers/request-transformer.ts:462", + "src/proxy/transformers/request-transformer.ts:471", + "src/proxy/transformers/request-transformer.ts:490", + "src/proxy/transformers/request-transformer.ts:496", + "src/proxy/transformers/request-transformer.ts:531", + "src/proxy/transformers/request-transformer.ts:536", + "src/proxy/transformers/request-transformer.ts:541", + "src/proxy/transformers/request-transformer.ts:546", + "src/proxy/transformers/request-transformer.ts:564", + "src/proxy/transformers/request-transformer.ts:569", + "src/proxy/transformers/request-transformer.ts:576", + "src/proxy/transformers/request-transformer.ts:585", + "src/proxy/transformers/request-transformer.ts:636", + "src/proxy/transformers/request-transformer.ts:642", + "src/proxy/transformers/request-transformer.ts:647", + "src/proxy/transformers/request-transformer.ts:668", "src/proxy/upstream-url.ts:28", "src/shared/claude-extension-setup.ts:240", "src/shared/claude-extension-setup.ts:247", @@ -237,10 +233,6 @@ "src/targets/codex-adapter.ts:319", "src/targets/codex-adapter.ts:326", "src/targets/codex-adapter.ts:363", - "src/targets/codex-cliproxy-provider-config.ts:138", - "src/targets/codex-cliproxy-provider-config.ts:141", - "src/targets/codex-cliproxy-provider-config.ts:151", - "src/targets/codex-cliproxy-provider-config.ts:177", "src/targets/droid-adapter.ts:32", "src/targets/droid-adapter.ts:35", "src/targets/droid-adapter.ts:68", From cee982200813f9eda357aad25b6daa6fad80ef0d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 16:34:30 -0400 Subject: [PATCH 23/29] fix(cliproxy): surface Control Panel URL and login key in status/start --- .../proxy-lifecycle-control-panel.test.ts | 89 +++++++++++++++++++ src/commands/cliproxy/help-subcommand.ts | 5 +- .../cliproxy/proxy-lifecycle-subcommand.ts | 22 +++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 src/commands/cliproxy/__tests__/proxy-lifecycle-control-panel.test.ts diff --git a/src/commands/cliproxy/__tests__/proxy-lifecycle-control-panel.test.ts b/src/commands/cliproxy/__tests__/proxy-lifecycle-control-panel.test.ts new file mode 100644 index 00000000..48aa735b --- /dev/null +++ b/src/commands/cliproxy/__tests__/proxy-lifecycle-control-panel.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for printControlPanelAccess: the Control Panel (API Management Center) + * URL + login-key surface shown by `ccs cliproxy status` / `start`. + * + * Why this matters: + * - The Control Panel login screen only asks for a "Management Key" with no + * hint. Users who don't know the default (`ccs`) cannot get in. This surface + * is the fix, so it must print the panel URL on the active port and the + * EFFECTIVE key (default `ccs`, or the user's custom management_secret). + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; + +import { printControlPanelAccess } from '../proxy-lifecycle-subcommand'; +import { invalidateConfigCache, mutateConfig } from '../../../config/config-loader-facade'; + +function createTestHome(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-control-panel-test-')); + const ccsDir = path.join(dir, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'cliproxy', 'auth'), { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8'); + return dir; +} + +describe('printControlPanelAccess', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let logSpy: ReturnType; + let lines: string[]; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + invalidateConfigCache(); + lines = []; + logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => { + lines.push(String(msg ?? '')); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + invalidateConfigCache(); + try { + fs.rmSync(tempHome, { recursive: true, force: true }); + } catch { + /* best-effort cleanup */ + } + }); + + it('prints the panel URL on the given port and the default key (ccs)', () => { + printControlPanelAccess(8317); + const out = lines.join('\n'); + expect(out).toContain('http://127.0.0.1:8317/management.html'); + expect(out).toContain('Panel login key:'); + expect(out).toContain('ccs'); + }); + + it('uses the active port in the URL', () => { + printControlPanelAccess(9000); + expect(lines.join('\n')).toContain('http://127.0.0.1:9000/management.html'); + }); + + it('prints a custom management_secret when configured', () => { + mutateConfig((config) => { + if (!config.cliproxy) { + config.cliproxy = {}; + } + if (!config.cliproxy.auth) { + config.cliproxy.auth = {}; + } + config.cliproxy.auth.management_secret = 'my-custom-key'; + }); + invalidateConfigCache(); + + printControlPanelAccess(8317); + const out = lines.join('\n'); + expect(out).toContain('Panel login key:'); + expect(out).toContain('my-custom-key'); + }); +}); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 4049a616..9fb96480 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -89,7 +89,10 @@ export async function showHelp(): Promise { [ ['start', 'Start CLIProxy instance in background'], ['restart', 'Restart CLIProxy instance'], - ['status [--verbose]', 'Show running CLIProxy status (--verbose adds uptime)'], + [ + 'status [--verbose]', + 'Show CLIProxy status + Control Panel URL and login key (--verbose adds uptime)', + ], ['stop', 'Stop running CLIProxy instance'], ['doctor | diag', 'Quota diagnostics and shared project detection'], ], diff --git a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts index 7c4e4462..6794260d 100644 --- a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts +++ b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts @@ -12,6 +12,23 @@ import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui'; import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services'; import { detectRunningProxy } from '../../cliproxy/proxy/proxy-detector'; import { resolveLifecyclePort } from '../../cliproxy/config/port-manager'; +import { getEffectiveManagementSecret } from '../../cliproxy/auth/auth-token-manager'; + +/** + * Print how to reach the local CLIProxy Control Panel (a.k.a. API Management + * Center) and the key needed to log in. + * + * The panel is served by CLIProxy at `/management.html` on the proxy port and + * its login is gated by the management secret (default `ccs`). The login screen + * only asks for a "Management Key" with no hint, so users frequently cannot get + * in. Surfacing the URL + resolved key here removes that guesswork. + */ +export function printControlPanelAccess(port: number): void { + const secret = getEffectiveManagementSecret(); + console.log(''); + console.log(` Control Panel: http://127.0.0.1:${port}/management.html`); + console.log(` Panel login key: ${secret}`); +} export async function handleStart(verbose = false): Promise { await initUI(); @@ -30,6 +47,9 @@ export async function handleStart(verbose = false): Promise { console.log(ok(`CLIProxy started on port ${result.port}`)); } console.log(dim('To stop: ccs cliproxy stop')); + if (result.port) { + printControlPanelAccess(result.port); + } } else { console.log(warn(result.error || 'Failed to start CLIProxy')); } @@ -93,6 +113,7 @@ export async function handleProxyStatus(verbose = false): Promise { console.log(''); console.log(dim('To stop: ccs cliproxy stop')); + printControlPanelAccess(status.port ?? port); } else { // Fallback: detect untracked/orphaned proxy process (e.g. detached session without lock file). const detected = await detectRunningProxy(port); @@ -109,6 +130,7 @@ export async function handleProxyStatus(verbose = false): Promise { } console.log(''); console.log(dim('To stop: ccs cliproxy stop')); + printControlPanelAccess(port); } else { console.log(` Status: ${color('Not running', 'warning')}`); console.log(''); From 824935adf6ad494689575177ab663346f852b224 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Jun 2026 20:38:00 +0000 Subject: [PATCH 24/29] chore(release): 8.5.0-dev.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 30a40cfb..de1620ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.5.0-dev.2", + "version": "8.5.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From c02c4a7d5816b89bd3a6cfd7f65df65228a45293 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Jun 2026 20:46:14 +0000 Subject: [PATCH 25/29] chore(release): 8.5.0-dev.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de1620ea..c9159e6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.5.0-dev.3", + "version": "8.5.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From d263fbc2ec3e780c8001a4d80654444cd2f65276 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 20:59:52 -0400 Subject: [PATCH 26/29] chore(lint): regenerate throw-error baseline for shifted provider-preset-catalog throws --- eslint-rules/throw-error-baseline.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index 57d9457e..9cbbe875 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -220,13 +220,13 @@ "src/shared/claude-extension-setup.ts:247", "src/shared/claude-extension-setup.ts:257", "src/shared/claude-extension-setup.ts:317", - "src/shared/provider-preset-catalog.ts:308", - "src/shared/provider-preset-catalog.ts:311", - "src/shared/provider-preset-catalog.ts:320", - "src/shared/provider-preset-catalog.ts:323", - "src/shared/provider-preset-catalog.ts:326", - "src/shared/provider-preset-catalog.ts:331", - "src/shared/provider-preset-catalog.ts:334", + "src/shared/provider-preset-catalog.ts:325", + "src/shared/provider-preset-catalog.ts:328", + "src/shared/provider-preset-catalog.ts:337", + "src/shared/provider-preset-catalog.ts:340", + "src/shared/provider-preset-catalog.ts:343", + "src/shared/provider-preset-catalog.ts:348", + "src/shared/provider-preset-catalog.ts:351", "src/shared/toml-object.ts:23", "src/targets/codex-adapter.ts:103", "src/targets/codex-adapter.ts:275", From 0a2c549268380fef29c5b73c0342c7614c3cf9e6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 21:00:02 -0400 Subject: [PATCH 27/29] feat(bar): one-click in-app updater with update-available notification Adds a lightweight self-update path to the CCS Bar menu-bar app so users are told when a newer build is available and can update with one click. - App: BarUpdateChecker fetches the published version.txt and compares it to the running bundle version (numeric semver). A throttled background check (post-launch, then every 6h) surfaces an "Update available" row in the menu; "Update Now" spawns `ccs bar install --launch --await-quit` then quits so the installer can swap the locked bundle and relaunch. A Settings toggle gates the auto-check (default on). Offline checks degrade silently. - CLI: `ccs bar install --await-quit` waits for a running CCS Bar to quit (300ms poll, 15s cap) before swapping, enabling the one-click relaunch. - Release: bar-release publishes a machine-readable version.txt asset beside the app zip so the check is cheap (no full download just to compare). - Shares the ccs-binary resolver between DashboardLauncher and the updater. - Bumps bar VERSION to 1.8.0; adds isNewer assertions to ccs-bar-check. Closes #1588 --- .github/workflows/bar-release.yml | 4 + macos-bar/Sources/CCSBarApp/BarMenuView.swift | 59 ++++++++- .../Sources/CCSBarApp/BarPreferences.swift | 21 ++++ .../CCSBarApp/BarPreferencesView.swift | 15 +++ .../Sources/CCSBarApp/BarViewModel.swift | 118 ++++++++++++++++++ .../Sources/CCSBarApp/DashboardLauncher.swift | 38 ++++-- macos-bar/Sources/CCSBarCheck/main.swift | 22 ++++ .../Sources/CCSBarCore/BarUpdateChecker.swift | 85 +++++++++++++ macos-bar/VERSION | 2 +- src/commands/bar/help-subcommand.ts | 4 + src/commands/bar/install-subcommand.ts | 33 ++++- 11 files changed, 383 insertions(+), 18 deletions(-) create mode 100644 macos-bar/Sources/CCSBarCore/BarUpdateChecker.swift diff --git a/.github/workflows/bar-release.yml b/.github/workflows/bar-release.yml index a89b68cb..db1220d5 100644 --- a/.github/workflows/bar-release.yml +++ b/.github/workflows/bar-release.yml @@ -60,6 +60,10 @@ jobs: # gh lives in Homebrew on the self-hosted runner; ensure it is on PATH. export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" gh release upload ccs-bar-latest macos-bar/dist/CCS-Bar.app.zip --clobber + printf '%s\n' "${VERSION}" > version.txt + gh release upload ccs-bar-latest version.txt --clobber + echo "[OK] Published version.txt (${VERSION}) to ccs-bar-latest" + rm -f version.txt notes="$(mktemp)" cat > "$notes" <? /// Guard against overlapping start-and-connect sequences. private var connectTask: Task? + /// Background update-check task — kept so it can be cancelled on deinit. + private var updateCheckTask: Task? + /// Tracks when the last successful remote version check ran so we never + /// hammer the release URL more than once every 6 hours. + private var lastUpdateCheckDate: Date? = nil private let prefs: BarPreferences private let notifier: NotificationDelivering private let probe: BarServerProbe @@ -79,6 +97,7 @@ final class BarViewModel: ObservableObject { self.spendPeriod = SpendPeriodStore.load() reconnect() startBackgroundPolling() + startUpdateChecking() } // MARK: - Background polling @@ -99,6 +118,105 @@ final class BarViewModel: ObservableObject { } } + // MARK: - Update checking + + /// Minimum interval between remote version checks (6 hours in seconds). + private static let updateCheckInterval: TimeInterval = 6 * 60 * 60 + + /// Starts a long-lived background task that checks for updates once shortly + /// after launch and then at most once every 6 hours, respecting the + /// `autoCheckUpdates` preference. Never blocks the UI. + private func startUpdateChecking() { + updateCheckTask?.cancel() + // Mirror startBackgroundPolling: weak capture, re-unwrap `self` inside the + // loop each iteration so the closure never strongly retains the view model. + updateCheckTask = Task { [weak self] in + // Brief initial delay so launch completes before the first network call. + try? await Task.sleep(for: .seconds(5)) + // First check fires immediately; subsequent ones wake every 30 minutes to + // test the 6-hour gate, so a machine resuming from sleep re-checks without + // a relaunch. + while !Task.isCancelled { + guard let self else { return } + await self.runUpdateCheckIfNeeded() + try? await Task.sleep(for: .seconds(30 * 60)) + } + } + } + + /// Runs the remote version check when the preference is enabled and the + /// 6-hour throttle permits. Safe to call from any context. + private func runUpdateCheckIfNeeded() async { + guard prefs.autoCheckUpdates else { return } + if let last = lastUpdateCheckDate, + Date().timeIntervalSince(last) < BarViewModel.updateCheckInterval + { + return + } + lastUpdateCheckDate = Date() + await performUpdateCheck() + } + + /// Fetches the latest published version and updates `updateAvailable` / + /// `latestVersion`. The network call is nonisolated; the published-state + /// mutation happens back on the main actor (this class is @MainActor, so + /// the assignment after `await` re-enters the main actor automatically). + private func performUpdateCheck() async { + // Hop off main actor for the network call so we don't block the UI. + let latest = await Task.detached(priority: .utility) { + await BarUpdateChecker.fetchLatestPublishedVersion() + }.value + + // Back on the main actor (implicit — this method is on @MainActor class). + let current = + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" + if let v = latest, BarUpdateChecker.isNewer(v, than: current) { + latestVersion = v + updateAvailable = true + } else { + latestVersion = nil + updateAvailable = false + } + } + + /// Launches `ccs bar install --launch --await-quit` as a detached process + /// that survives this app terminating, then schedules a short-delay quit so + /// the installer can swap the (locked) bundle while no app holds it. + func installUpdate() { + guard !isInstallingUpdate else { return } + isInstallingUpdate = true + + guard let bin = CCSBinaryResolver.resolve() else { + print("[CCS Bar] installUpdate: ccs binary not found; cannot install update") + isInstallingUpdate = false + return + } + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: bin) + proc.arguments = ["bar", "install", "--launch", "--await-quit"] + // Detach stdio so the process is not tied to this app's file descriptors. + let null = FileHandle(forWritingAtPath: "/dev/null") + proc.standardOutput = null + proc.standardError = null + proc.standardInput = null + + do { + try proc.run() + // Do NOT call waitUntilExit — the installer must outlive us. + } catch { + print("[CCS Bar] installUpdate: failed to launch installer: \(error)") + isInstallingUpdate = false + return + } + + // Give the installer a moment to register, then quit so the bundle is + // unlocked and the installer can swap + relaunch CCS Bar. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { + NSApplication.shared.terminate(nil) + } + } + // MARK: - Prefs /// Re-read prefs after the preferences sheet writes through. diff --git a/macos-bar/Sources/CCSBarApp/DashboardLauncher.swift b/macos-bar/Sources/CCSBarApp/DashboardLauncher.swift index 137ee48f..625ec8fe 100644 --- a/macos-bar/Sources/CCSBarApp/DashboardLauncher.swift +++ b/macos-bar/Sources/CCSBarApp/DashboardLauncher.swift @@ -2,16 +2,16 @@ import AppKit import CCSBarCore import Foundation -/// Opens the CCS dashboard, starting the local server when it isn't running. -/// -/// The dashboard is just a page served by the CCS web-server, so it can't load -/// if no server is up. When the discovered URL isn't reachable we launch -/// `ccs config`, which boots the server AND opens the dashboard in the browser -/// itself — so we must not also open it (that would double-open a tab). -enum DashboardLauncher { - /// A GUI app does not inherit the shell PATH, so probe the common install - /// locations for the `ccs` binary explicitly. First executable match wins. - private static var ccsCandidates: [String] { +// MARK: - Shared ccs binary resolver + +/// Resolves the `ccs` binary path for GUI apps that don't inherit the shell +/// PATH. Shared by `DashboardLauncher` and `BarViewModel` so the candidate +/// list stays in one place (DRY). +enum CCSBinaryResolver { + /// Ordered list of candidate paths for the `ccs` binary. First executable + /// match wins. GUI apps don't inherit the shell PATH, so we must enumerate + /// common install locations explicitly. + static var candidates: [String] { let home = NSHomeDirectory() return [ "\(home)/.bun/bin/ccs", @@ -23,8 +23,24 @@ enum DashboardLauncher { ] } + /// Returns the path of the first executable `ccs` binary found, or `nil` + /// when none of the candidates exist. + static func resolve() -> String? { + candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } +} + +// MARK: - Dashboard launcher + +/// Opens the CCS dashboard, starting the local server when it isn't running. +/// +/// The dashboard is just a page served by the CCS web-server, so it can't load +/// if no server is up. When the discovered URL isn't reachable we launch +/// `ccs config`, which boots the server AND opens the dashboard in the browser +/// itself — so we must not also open it (that would double-open a tab). +enum DashboardLauncher { private static func ccsBinary() -> String? { - ccsCandidates.first { FileManager.default.isExecutableFile(atPath: $0) } + CCSBinaryResolver.resolve() } private static func dashboardURL() -> URL? { diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index f11dbfd7..0c9d5833 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -1664,6 +1664,28 @@ do { check(port3000Count == 1, "probe: bar.json port 3000 deduped — probed once, not twice") } +// (BarUpdateChecker) Semver comparison drives the in-app "Update available" +// affordance, so ordering must be numeric (1.10.0 > 1.9.0), tolerate +// pre-release suffixes, and reject malformed input. +do { + check(BarUpdateChecker.isNewer("1.8.0", than: "1.7.0"), "isNewer: newer minor (1.8.0 > 1.7.0)") + check(BarUpdateChecker.isNewer("1.7.1", than: "1.7.0"), "isNewer: newer patch (1.7.1 > 1.7.0)") + check(BarUpdateChecker.isNewer("2.0.0", than: "1.9.9"), "isNewer: newer major (2.0.0 > 1.9.9)") + check( + BarUpdateChecker.isNewer("1.10.0", than: "1.9.0"), + "isNewer: numeric not lexicographic (1.10.0 > 1.9.0)") + check(!BarUpdateChecker.isNewer("1.7.0", than: "1.7.0"), "isNewer: equal is not newer") + check(!BarUpdateChecker.isNewer("1.6.0", than: "1.7.0"), "isNewer: older is not newer") + check( + BarUpdateChecker.isNewer("1.8.0-beta.1", than: "1.7.0"), + "isNewer: pre-release suffix stripped, core still newer") + check( + !BarUpdateChecker.isNewer("1.7.0-beta.1", than: "1.7.0"), + "isNewer: equal core after stripping suffix is not newer") + check(!BarUpdateChecker.isNewer("garbage", than: "1.7.0"), "isNewer: malformed latest rejected") + check(!BarUpdateChecker.isNewer("1.7", than: "1.7.0"), "isNewer: fewer than 3 parts rejected") +} + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarUpdateChecker.swift b/macos-bar/Sources/CCSBarCore/BarUpdateChecker.swift new file mode 100644 index 00000000..d5339680 --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarUpdateChecker.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Lightweight update checker for CCS Bar. +/// +/// Self-contained: the fetch is the only side effect. `isNewer` is pure and +/// unit-test-friendly with no bundle or network dependency. +public enum BarUpdateChecker { + + /// Published-version artifact URL. Stable redirect target maintained by the + /// bar-release workflow; always points to the latest released version.txt. + private static let versionURL = URL( + string: + "https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/version.txt" + )! + + /// Validates that a string looks like a semver (permissive: pre-release + /// suffixes after `-` are allowed but optional). + private static let semverRegex = try! NSRegularExpression( + pattern: #"^\d+\.\d+\.\d+([-.0-9A-Za-z]*)?$"# + ) + + // MARK: - Public API + + /// Fetches the latest published version string from the stable release URL. + /// + /// - Returns: A trimmed semver string (e.g. `"1.8.0"`) when the remote + /// returns HTTP 200 with a recognisable semver body; `nil` on any error, + /// timeout, non-200 status, or invalid body. + /// - Never throws: all errors are swallowed and surfaced as `nil` so callers + /// never need a do/catch. + public static func fetchLatestPublishedVersion() async -> String? { + var request = URLRequest(url: versionURL) + request.timeoutInterval = 8 + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + return nil + } + guard let body = String(data: data, encoding: .utf8) else { return nil } + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard isValidSemver(trimmed) else { return nil } + return trimmed + } catch { + // Network errors, timeouts, cancellation — all silently nil. + return nil + } + } + + /// Compares two semver strings and returns `true` when `latest` is strictly + /// newer than `current`. + /// + /// - Strips any pre-release suffix (everything from the first `-` onward) + /// before comparing. Compares major, minor, patch as `Int`s. + /// - Returns `false` on any malformed input. + public static func isNewer(_ latest: String, than current: String) -> Bool { + guard + let latestParts = parseMajorMinorPatch(latest), + let currentParts = parseMajorMinorPatch(current) + else { return false } + + for (l, c) in zip(latestParts, currentParts) { + if l > c { return true } + if l < c { return false } + } + return false // equal + } + + // MARK: - Private helpers + + private static func isValidSemver(_ s: String) -> Bool { + let range = NSRange(s.startIndex..., in: s) + return semverRegex.firstMatch(in: s, range: range) != nil + } + + /// Split a semver into [major, minor, patch], stripping any pre-release suffix. + private static func parseMajorMinorPatch(_ version: String) -> [Int]? { + // Strip pre-release suffix (anything from the first `-` onward). + let core = version.split(separator: "-", maxSplits: 1).first.map(String.init) ?? version + let parts = core.split(separator: ".").compactMap { Int($0) } + guard parts.count >= 3 else { return nil } + return [parts[0], parts[1], parts[2]] + } +} diff --git a/macos-bar/VERSION b/macos-bar/VERSION index bd8bf882..27f9cd32 100644 --- a/macos-bar/VERSION +++ b/macos-bar/VERSION @@ -1 +1 @@ -1.7.0 +1.8.0 diff --git a/src/commands/bar/help-subcommand.ts b/src/commands/bar/help-subcommand.ts index 95b63095..fbc22031 100644 --- a/src/commands/bar/help-subcommand.ts +++ b/src/commands/bar/help-subcommand.ts @@ -34,6 +34,10 @@ export async function showHelp(): Promise { [ ['--launch', 'Launch CCS Bar immediately after install without prompting'], ['--no-launch', 'Skip the launch prompt after install'], + [ + '--await-quit', + 'Wait for a running CCS Bar to quit, then swap and relaunch (used by the in-app updater)', + ], ], ], [ diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index 96893ba4..d0fd2903 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -490,9 +490,10 @@ export async function handleBarInstall( args: string[], deps: Partial = {} ): Promise { - // Parse --launch / --no-launch flags before delegating to deps. + // Parse --launch / --no-launch / --await-quit flags before delegating to deps. const forceLaunch = hasAnyFlag(args, ['--launch']); const noLaunch = hasAnyFlag(args, ['--no-launch']); + const awaitQuit = hasAnyFlag(args, ['--await-quit']); const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset; const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract; @@ -584,6 +585,36 @@ export async function handleBarInstall( return; } + // 3b-pre. --await-quit: if the app is running, poll until it exits before swapping. + // Without this flag the existing behavior is preserved exactly (no change). + if (awaitQuit) { + const POLL_INTERVAL_MS = 300; + const TIMEOUT_MS = 15_000; + const deadline = Date.now() + TIMEOUT_MS; + + if (await isBarRunning()) { + console.log('[i] Waiting for CCS Bar to quit before swapping...'); + + let exited = false; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + if (!(await isBarRunning())) { + exited = true; + break; + } + } + + if (!exited) { + console.error( + '[!] CCS Bar is still running; quit it from the menu bar and re-run `ccs bar install`.' + ); + process.exitCode = 1; + cleanupStaging(); + return; + } + } + } + // 3b. New bundle verified in staging — now safe to remove the old install. if (fs.existsSync(appPath)) { try { From 239f83df9de368c59f0b7e33c2ea49e50b79bd35 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 21:07:13 -0400 Subject: [PATCH 28/29] test(bar): cover ccs bar install --await-quit relaunch path --- tests/unit/commands/bar-command.test.ts | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index b9778421..de542930 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -3420,3 +3420,55 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { expect(lowerPriorityProbeStarted).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// GH-1588 — `--await-quit` waits for a running app to exit, then swaps + relaunches +// --------------------------------------------------------------------------- + +describe('bar install: --await-quit waits for the running app to quit (GH-1588)', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + function baseDeps(appsDir: string, isBarRunning: () => Promise) { + return { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }), + downloadAndExtract: async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), + readAppBundleVersion: () => '1.0.0', + isBarRunning, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + }; + } + + it('proceeds with swap + relaunch once the running app exits', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + // Running on the first probe (still up), gone on every probe after — this + // models the in-app updater quitting itself right after spawning + // `ccs bar install --launch --await-quit`. + let probes = 0; + const isBarRunning = async () => { + probes += 1; + return probes === 1; + }; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall(['--launch', '--await-quit'], { + ...baseDeps(appsDir, isBarRunning), + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => false, + }); + + // The wait fired (app seen running first), then the install completed and + // relaunched into the new build. + expect(consoleOutput.join('\n')).toMatch(/Waiting for CCS Bar to quit/i); + expect(launchCalled).toBe(true); + expect(probes).toBeGreaterThanOrEqual(2); + }); +}); From 5f6566cd35dcde504f417708ca349134020e018c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 23 Jun 2026 01:19:59 +0000 Subject: [PATCH 29/29] chore(release): 8.5.0-dev.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c9159e6c..ca221cda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.5.0-dev.4", + "version": "8.5.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",