From c05189b4b1e2d90ee6bb88a4024feef1f8dbe1b1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Mar 2026 16:36:04 -0400 Subject: [PATCH 1/3] fix(cliproxy): preserve explicit Claude long-context intent - centralize Anthropic model-key [1m] handling in shared helpers - keep dashboard preset and editor writes consistent across all Claude tiers - add explicit api create and help guidance for --1m/--no-1m Refs #789 --- .../config/extended-context-config.ts | 66 ++++---------- src/cliproxy/model-id-normalizer.ts | 8 +- src/commands/api-command/create-command.ts | 85 +++++++++++++++++-- src/commands/api-command/help.ts | 15 ++++ src/commands/api-command/shared.ts | 53 +++++++++++- src/commands/help-command.ts | 17 ++-- src/shared/extended-context-utils.ts | 71 ++++++++++++++++ .../cliproxy/extended-context-toggle.tsx | 43 +++++----- .../provider-editor/model-config-section.tsx | 26 +++--- .../provider-editor/use-provider-editor.ts | 59 ++++++------- ui/src/lib/extended-context-utils.ts | 7 ++ ui/src/lib/model-catalogs.ts | 13 +++ 12 files changed, 329 insertions(+), 134 deletions(-) diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 38727e7f..7c63c68c 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -13,9 +13,10 @@ import type { CLIProxyProvider } from '../types'; import { supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; import { + applyExtendedContextPreferenceToAnthropicModels, applyExtendedContextSuffix as applyExtendedContextSuffixShared, isNativeGeminiModel, - stripExtendedContextSuffix, + stripModelConfigurationSuffixes, } from '../../shared/extended-context-utils'; // Backward-compatible export retained for tests/importers that reference this module. @@ -72,57 +73,20 @@ export function applyExtendedContextConfig( provider: CLIProxyProvider, extendedContextOverride?: boolean ): void { - // Get base model to check support (strip any existing suffixes for lookup) - const baseModel = envVars.ANTHROPIC_MODEL || ''; - const cleanModelId = stripModelSuffixes(baseModel); - - // Tier model env vars to apply/strip extended context suffix - const tierModels = [ - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - ] as const; - - if (!shouldApplyExtendedContext(provider, cleanModelId, extendedContextOverride)) { - // Strip [1m] suffix from models that no longer support extended context - // (e.g., user had it enabled before backend dropped support) - if (envVars.ANTHROPIC_MODEL?.toLowerCase().endsWith('[1m]')) { - envVars.ANTHROPIC_MODEL = envVars.ANTHROPIC_MODEL.replace(/\[1m\]$/i, ''); - } - for (const tierVar of tierModels) { - const model = envVars[tierVar]; - if (model?.toLowerCase().endsWith('[1m]')) { - envVars[tierVar] = model.replace(/\[1m\]$/i, ''); - } - } + if (extendedContextOverride === false) { + Object.assign(envVars, applyExtendedContextPreferenceToAnthropicModels(envVars, false)); return; } - // Apply suffix to main model - if (envVars.ANTHROPIC_MODEL) { - envVars.ANTHROPIC_MODEL = applyExtendedContextSuffixShared(envVars.ANTHROPIC_MODEL); - } - - // Apply to tier models if they support extended context - - for (const tierVar of tierModels) { - const model = envVars[tierVar]; - if (model) { - const tierCleanId = stripModelSuffixes(model); - if (shouldApplyExtendedContext(provider, tierCleanId, extendedContextOverride)) { - envVars[tierVar] = applyExtendedContextSuffixShared(model); - } - } - } -} - -/** - * Strip thinking and extended context suffixes from model ID for catalog lookup. - * Examples: - * "gemini-2.5-pro(high)[1m]" -> "gemini-2.5-pro" - * "gemini-2.5-pro(8192)" -> "gemini-2.5-pro" - * "gemini-2.5-pro" -> "gemini-2.5-pro" - */ -function stripModelSuffixes(modelId: string): string { - return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); + Object.assign( + envVars, + applyExtendedContextPreferenceToAnthropicModels(envVars, true, { + supportsExtendedContext: (modelId) => + shouldApplyExtendedContext( + provider, + stripModelConfigurationSuffixes(modelId), + extendedContextOverride + ), + }) + ); } diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 99761bfa..d2719d2c 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -6,14 +6,10 @@ */ import type { CLIProxyProvider } from './types'; +import { ANTHROPIC_MODEL_ENV_KEYS } from '../shared/extended-context-utils'; /** Env vars that carry model identifiers. */ -export const MODEL_ENV_VAR_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', -] as const; +export const MODEL_ENV_VAR_KEYS = ANTHROPIC_MODEL_ENV_KEYS; type ProviderLike = CLIProxyProvider | string | null | undefined; diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index dbd798a6..9d9da93f 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -25,7 +25,13 @@ import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync'; import type { TargetType } from '../../targets/target-adapter'; import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; -import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared'; +import { + applyClaudeExtendedContextPreference, + exitOnApiCommandErrors, + hasClaudeModelMapping, + hasExplicitClaudeExtendedContext, + parseApiCommandArgs, +} from './shared'; function resolvePresetOrExit(presetId?: string): ProviderPreset | null { if (!presetId) { @@ -276,6 +282,37 @@ async function resolveDefaultTarget( return useDroidByDefault ? 'droid' : 'claude'; } +async function resolveClaudeLongContextPreference( + models: ModelMapping, + explicitPreference: boolean | undefined, + yes: boolean | undefined +): Promise { + if (explicitPreference !== undefined) { + return explicitPreference; + } + + if (hasExplicitClaudeExtendedContext(models)) { + return true; + } + + if (yes) { + return false; + } + + console.log(''); + console.log(info('Claude long context is explicit in CCS.')); + console.log(dim(' Plain Claude model IDs stay on standard context unless you opt into [1m].')); + console.log( + dim(' Some providers/accounts still require extra usage or PAYG for long-context requests.') + ); + console.log(dim(' If that entitlement is missing, upstream requests can still return 429.')); + console.log(''); + + return InteractivePrompt.confirm('Enable [1m] for compatible Claude mappings?', { + default: false, + }); +} + export async function handleApiCreateCommand(args: string[]): Promise { await initUI(); const parsedArgs = parseApiCommandArgs(args); @@ -403,17 +440,31 @@ export async function handleApiCreateCommand(args: string[]): Promise { } const apiKey = await resolveApiKey(parsedArgs.apiKey, preset); - const { model, models } = await resolveModelConfiguration( + const { models } = await resolveModelConfiguration( baseUrl, preset, parsedArgs.model, parsedArgs.yes ); + const hasClaudeMappings = hasClaudeModelMapping(models); + const shouldEnableClaudeLongContext = hasClaudeMappings + ? await resolveClaudeLongContextPreference(models, parsedArgs.extendedContext, parsedArgs.yes) + : false; + const finalModels = hasClaudeMappings + ? applyClaudeExtendedContextPreference(models, shouldEnableClaudeLongContext) + : models; const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + if (parsedArgs.extendedContext !== undefined && !hasClaudeMappings) { + console.log(''); + console.log( + dim('No compatible Claude mappings were detected, so --1m/--no-1m did not change models.') + ); + } + console.log(''); console.log(info('Creating API profile...')); - const result = createApiProfile(name, baseUrl || '', apiKey, models, target); + const result = createApiProfile(name, baseUrl || '', apiKey, finalModels, target); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); process.exit(1); @@ -427,25 +478,43 @@ export async function handleApiCreateCommand(args: string[]): Promise { } const hasCustomMapping = - models.opus !== model || models.sonnet !== model || models.haiku !== model; + finalModels.opus !== finalModels.default || + finalModels.sonnet !== finalModels.default || + finalModels.haiku !== finalModels.default; let details = `API: ${name}\n` + `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + `Settings: ${result.settingsFile}\n` + `Base URL: ${baseUrl}\n` + - `Model: ${model}\n` + + `Model: ${finalModels.default}\n` + `Target: ${target}`; + if (hasClaudeMappings) { + details += `\nLongCtx: ${ + shouldEnableClaudeLongContext ? 'compatible Claude mappings use [1m]' : 'standard Claude context' + }`; + } + if (hasCustomMapping) { details += `\n\nModel Mapping:\n` + - ` Opus: ${models.opus}\n` + - ` Sonnet: ${models.sonnet}\n` + - ` Haiku: ${models.haiku}`; + ` Opus: ${finalModels.opus}\n` + + ` Sonnet: ${finalModels.sonnet}\n` + + ` Haiku: ${finalModels.haiku}`; } console.log(''); console.log(infoBox(details, 'API Profile Created')); + if (hasClaudeMappings) { + console.log(''); + console.log( + dim( + shouldEnableClaudeLongContext + ? 'CCS saved [1m] on compatible Claude mappings. Provider-side extra-usage requirements can still reject long-context requests.' + : 'Claude mappings were saved with standard context. Use --1m later if you want CCS to write [1m] explicitly.' + ) + ); + } console.log(''); console.log(header('Usage')); if (target === 'droid') { diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index c9b3fb99..b2fb7f94 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -55,6 +55,9 @@ export async function showApiCommandHelp(): Promise { console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); console.log(` ${color('--model ', 'command')} Default model (create)`); + console.log( + ` ${color('--1m / --no-1m', 'command')} Write or clear [1m] on compatible Claude mappings` + ); console.log( ` ${color('--target ', 'command')} Default target: claude or droid (create)` ); @@ -84,11 +87,23 @@ export async function showApiCommandHelp(): Promise { console.log(''); console.log(` ${dim('# Quick setup with preset')}`); console.log(` ${color('ccs api create --preset anthropic', 'command')}`); + console.log( + ` ${color('ccs api create --preset anthropic --1m', 'command')} ${dim('# explicit Claude [1m] opt-in')}` + ); console.log(` ${color('ccs api create --preset openrouter', 'command')}`); console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); console.log(` ${color('ccs api create --preset glm', 'command')}`); console.log(''); + console.log(subheader('Claude Long Context')); + console.log(` ${dim('Plain Claude model IDs stay on standard context by default.')}`); + console.log( + ` ${dim('Use --1m during create to append [1m] to compatible Claude mappings, or --no-1m to force plain IDs.')}` + ); + console.log( + ` ${dim('CCS controls only the saved [1m] suffix. Provider pricing/entitlement stay upstream, and some accounts can still return 429 for long-context requests.')}` + ); + console.log(''); console.log(` ${dim('# Create routed profile from existing CLIProxy provider config')}`); console.log(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`); console.log( diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index de42e70b..2e725dd0 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -1,4 +1,12 @@ +import type { ModelMapping } from '../../api/services'; import type { TargetType } from '../../targets/target-adapter'; +import { + applyExtendedContextSuffix, + hasExtendedContextSuffix, + isClaudeModelId, + likelySupportsClaudeExtendedContext, + stripExtendedContextSuffix, +} from '../../shared/extended-context-utils'; import { fail } from '../../utils/ui'; import { extractOption, hasAnyFlag, scanCommandArgs } from '../arg-extractor'; @@ -11,12 +19,15 @@ export interface ApiCommandArgs { preset?: string; cliproxyProvider?: string; target?: TargetType; + extendedContext?: boolean; force?: boolean; yes?: boolean; errors: string[]; } -export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; +const MODEL_MAPPING_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; + +export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y', '--1m', '--no-1m'] as const; export const API_VALUE_FLAGS = [ '--base-url', '--api-key', @@ -155,6 +166,8 @@ export function parseApiCommandArgs( args: string[], options: ParseApiCommandArgsOptions = {} ): ApiCommandArgs { + const enableExtendedContext = hasAnyFlag(args, ['--1m']); + const disableExtendedContext = hasAnyFlag(args, ['--no-1m']); const result: ApiCommandArgs = { positionals: [], force: hasAnyFlag(args, ['--force']), @@ -162,6 +175,14 @@ export function parseApiCommandArgs( errors: [], }; + if (enableExtendedContext && disableExtendedContext) { + result.errors.push('Cannot combine --1m and --no-1m'); + } else if (enableExtendedContext) { + result.extendedContext = true; + } else if (disableExtendedContext) { + result.extendedContext = false; + } + let remaining = [...args]; remaining = applyRepeatedOption( @@ -251,6 +272,36 @@ export function parseApiCommandArgs( return result; } +export function hasClaudeModelMapping(models: ModelMapping): boolean { + return MODEL_MAPPING_KEYS.some((key) => isClaudeModelId(models[key])); +} + +export function hasExplicitClaudeExtendedContext(models: ModelMapping): boolean { + return MODEL_MAPPING_KEYS.some( + (key) => isClaudeModelId(models[key]) && hasExtendedContextSuffix(models[key]) + ); +} + +export function applyClaudeExtendedContextPreference( + models: ModelMapping, + enabled: boolean +): ModelMapping { + const nextModels = { ...models }; + + for (const key of MODEL_MAPPING_KEYS) { + const value = nextModels[key]; + if (!isClaudeModelId(value)) { + continue; + } + + nextModels[key] = enabled && likelySupportsClaudeExtendedContext(value) + ? applyExtendedContextSuffix(value) + : stripExtendedContextSuffix(value); + } + + return nextModels; +} + export function exitOnApiCommandErrors(errors: string[]): void { if (errors.length === 0) { return; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 756d8022..80e203a8 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -220,8 +220,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', ], ['ccs codex --effort ', 'Set codex reasoning effort (medium/high/xhigh)'], - ['ccs --1m', 'Enable 1M token context window'], - ['ccs --no-1m', 'Disable 1M context (use 200K default)'], + ['ccs --1m', 'Request explicit 1M context when the selected model supports [1m]'], + ['ccs --no-1m', 'Force standard context / clear [1m]'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], ['ccs --port-forward', 'Force port-forwarding mode (skip prompt)'], @@ -449,14 +449,15 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Extended Context (1M) printSubSection('Extended Context (--1m)', [ - ['--1m', 'Enable 1M token context window'], - ['--no-1m', 'Disable 1M context (use 200K default)'], + ['--1m', 'Request 1M token context when the selected model supports [1m]'], + ['--no-1m', 'Force standard context (Claude default stays plain)'], ['', ''], - ['Auto behavior:', 'Gemini models: auto-enabled by default'], - ['', 'Claude models: opt-in with --1m flag'], + ['Auto behavior:', 'Gemini models: CCS auto-adds [1m] when supported'], + ['', 'Claude models: plain by default, opt-in with --1m or saved [1m]'], ['', ''], - ['Note:', 'Extended context enables 1M token window via [1m] suffix.'], - ['', 'Premium pricing: 2x input for >200K tokens.'], + ['Note:', 'CCS only controls the saved [1m] suffix.'], + ['', 'Provider pricing and entitlement stay upstream.'], + ['', 'Some accounts/providers can still return 429 extra-usage errors for long-context requests.'], ]); // Image Analysis diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 88b0486a..d8008462 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -4,6 +4,16 @@ /** Extended context suffix recognized by Claude Code. */ export const EXTENDED_CONTEXT_SUFFIX = '[1m]'; +export const ANTHROPIC_MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; + +export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; + +const ANTHROPIC_MODEL_ENV_KEY_SET = new Set(ANTHROPIC_MODEL_ENV_KEYS); /** Check if model is a native Gemini model (auto-enabled behavior). */ export function isNativeGeminiModel(modelId: string): boolean { @@ -27,3 +37,64 @@ export function stripExtendedContextSuffix(model: string): string { if (!model) return model; return hasExtendedContextSuffix(model) ? model.slice(0, -EXTENDED_CONTEXT_SUFFIX.length) : model; } + +/** True when key belongs to Anthropic model mapping state. */ +export function isAnthropicModelEnvKey(key: string): key is AnthropicModelEnvKey { + return ANTHROPIC_MODEL_ENV_KEY_SET.has(key); +} + +/** Strip transient config suffixes so model IDs can be checked against catalogs. */ +export function stripModelConfigurationSuffixes(modelId: string): string { + return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); +} + +/** Whether any saved Anthropic model mapping explicitly requests [1m]. */ +export function hasAnthropicExtendedContextEnabled( + env: Partial> +): boolean { + return ANTHROPIC_MODEL_ENV_KEYS.some((key) => { + const value = env[key]; + return typeof value === 'string' && hasExtendedContextSuffix(value); + }); +} + +/** Apply or strip [1m] across Anthropic model mappings while honoring compatibility. */ +export function applyExtendedContextPreferenceToAnthropicModels< + T extends Record, +>( + env: T, + enabled: boolean, + options: { + supportsExtendedContext?: (modelId: string, key: AnthropicModelEnvKey) => boolean; + } = {} +): T { + const nextEnv: Record = { ...env }; + + for (const key of ANTHROPIC_MODEL_ENV_KEYS) { + const value = nextEnv[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + continue; + } + + const modelId = stripModelConfigurationSuffixes(value); + const supported = options.supportsExtendedContext?.(modelId, key) ?? true; + nextEnv[key] = + enabled && supported ? applyExtendedContextSuffix(value) : stripExtendedContextSuffix(value); + } + + return nextEnv as T; +} + +/** Detect Claude model identifiers, regardless of transient [1m]/(thinking) suffixes. */ +export function isClaudeModelId(modelId: string): boolean { + return stripModelConfigurationSuffixes(modelId).toLowerCase().startsWith('claude-'); +} + +/** + * Conservative Claude long-context support heuristic for generic API profile flows. + * Haiku stays plain; Opus/Sonnet default to opt-in [1m]. + */ +export function likelySupportsClaudeExtendedContext(modelId: string): boolean { + const baseModel = stripModelConfigurationSuffixes(modelId).toLowerCase(); + return baseModel.startsWith('claude-') && !baseModel.startsWith('claude-haiku-'); +} diff --git a/ui/src/components/cliproxy/extended-context-toggle.tsx b/ui/src/components/cliproxy/extended-context-toggle.tsx index a5ec5f22..435d3d90 100644 --- a/ui/src/components/cliproxy/extended-context-toggle.tsx +++ b/ui/src/components/cliproxy/extended-context-toggle.tsx @@ -1,7 +1,6 @@ /** * Extended Context Toggle Component - * Shows toggle for models that support 1M token context window. - * Only visible when selected model has extendedContext: true. + * Shows toggle when any selected mapping supports 1M token context. */ import { Zap, Info } from 'lucide-react'; @@ -12,8 +11,8 @@ import { isNativeGeminiModel } from '@/lib/extended-context-utils'; import type { ModelEntry } from './provider-model-selector'; interface ExtendedContextToggleProps { - /** Currently selected model */ - model: ModelEntry | undefined; + /** Compatible selected models */ + models: ModelEntry[]; /** Provider name for display */ provider: string; /** Whether extended context is enabled */ @@ -27,19 +26,28 @@ interface ExtendedContextToggleProps { } export function ExtendedContextToggle({ - model, - provider, + models, + provider: _provider, enabled, onToggle, disabled, className, }: ExtendedContextToggleProps) { - // Only show if model supports extended context - if (!model?.extendedContext) { + if (models.length === 0) { return null; } - const isAutoEnabled = isNativeGeminiModel(model.id); + const hasGeminiModels = models.some((model) => isNativeGeminiModel(model.id)); + const hasClaudeModels = models.some((model) => !isNativeGeminiModel(model.id)); + + let behaviorHint = 'Compatible mappings stay on standard context unless you turn this on.'; + if (hasGeminiModels && hasClaudeModels) { + behaviorHint = + 'Gemini-compatible mappings can use 1M automatically. Claude mappings stay plain unless you turn this on.'; + } else if (hasGeminiModels) { + behaviorHint = + 'Gemini-compatible mappings can use 1M by default. Turn this off to keep standard context.'; + } return (
-

Enables 1M token context window instead of default 200K.

-

- {isAutoEnabled ? ( - Auto-enabled for {provider} Gemini models - ) : ( - Opt-in for {provider} Claude models via --1m flag - )} +

Applies the explicit [1m] long-context suffix to compatible saved mappings.

+

{behaviorHint}

+

+ CCS only saves [1m]. Provider pricing and entitlement are separate, and + some accounts can still return 429 extra-usage errors for long-context requests.

- {enabled && ( -

- Note: 2x input pricing applies for tokens beyond 200K -

- )}
diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index d22a9632..d93df27a 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -11,6 +11,7 @@ import { Sparkles, Zap, Star, X, Plus } from 'lucide-react'; import { FlexibleModelSelector } from '../provider-model-selector'; import { ExtendedContextToggle } from '../extended-context-toggle'; import { stripExtendedContextSuffix } from '@/lib/extended-context-utils'; +import { findCatalogModel } from '@/lib/model-catalogs'; import type { ModelConfigSectionProps } from './types'; type CatalogPresetModel = NonNullable['models'][number]; @@ -48,13 +49,18 @@ export function ModelConfigSection({ onDeletePreset, isDeletePending, }: ModelConfigSectionProps) { - // Find current model entry to check for extended context support - // Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix - const currentModelEntry = useMemo(() => { - if (!catalog || !currentModel) return undefined; - const baseModelId = stripExtendedContextSuffix(currentModel); - return catalog.models.find((m) => m.id === baseModelId); - }, [catalog, currentModel]); + const extendedContextModels = useMemo(() => { + if (!catalog) return []; + + const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel] + .filter((modelId): modelId is string => Boolean(modelId)) + .map((modelId) => stripExtendedContextSuffix(modelId)); + + const uniqueIds = [...new Set(selectedModels)]; + return uniqueIds + .map((modelId) => findCatalogModel(catalog.provider, modelId)) + .filter((model): model is NonNullable => Boolean(model?.extendedContext)); + }, [catalog, currentModel, opusModel, sonnetModel, haikuModel]); const presetGroups = useMemo(() => { const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping); @@ -199,10 +205,10 @@ export function ModelConfigSection({ catalog={catalog} allModels={providerModels} /> - {/* Extended Context Toggle - only shows for models that support it */} - {currentModelEntry?.extendedContext && onExtendedContextToggle && ( + {/* Extended Context Toggle - shows when any saved mapping supports it */} + {extendedContextModels.length > 0 && onExtendedContextToggle && ( { - const env = currentSettings?.env || {}; - return MODEL_ENV_KEYS.some((key) => { - const value = env[key]; - return value && hasExtendedContextSuffix(value); - }); + return hasAnthropicExtendedContextEnabled(currentSettings?.env || {}); }, [currentSettings]); + const applySavedLongContextIntent = useCallback( + (env: Record, enabled: boolean) => + applyExtendedContextPreferenceToAnthropicModels(env, enabled, { + supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId), + }), + [provider] + ); + // Update a single setting value const updateEnvValue = useCallback( (key: string, value: string) => { const newEnv = { ...(currentSettings?.env || {}), [key]: value }; - const newSettings = { ...currentSettings, env: newEnv }; + const envWithIntent = isAnthropicModelEnvKey(key) + ? applySavedLongContextIntent(newEnv, extendedContextEnabled) + : newEnv; + delete envWithIntent['CCS_EXTENDED_CONTEXT']; + + const newSettings = { ...currentSettings, env: envWithIntent }; setRawJsonEdits(JSON.stringify(newSettings, null, 2)); }, - [currentSettings] + [applySavedLongContextIntent, currentSettings, extendedContextEnabled] ); // Toggle extended context - applies/strips [1m] suffix to all model env vars const toggleExtendedContext = useCallback( (enabled: boolean) => { const env = currentSettings?.env || {}; - const updates: Record = {}; - - for (const key of MODEL_ENV_KEYS) { - const value = env[key]; - if (value) { - updates[key] = enabled - ? applyExtendedContextSuffix(value) - : stripExtendedContextSuffix(value); - } - } - - // Remove the legacy flag if present - const newEnv = { ...env, ...updates }; + const newEnv = applySavedLongContextIntent(env, enabled); delete newEnv['CCS_EXTENDED_CONTEXT']; const newSettings = { ...currentSettings, env: newEnv }; setRawJsonEdits(JSON.stringify(newSettings, null, 2)); }, - [currentSettings] + [applySavedLongContextIntent, currentSettings] ); // Batch update multiple env values at once const updateEnvValues = useCallback( (updates: Record) => { const newEnv = { ...(currentSettings?.env || {}), ...updates }; - const newSettings = { ...currentSettings, env: newEnv }; + const touchesAnthropicModel = Object.keys(updates).some(isAnthropicModelEnvKey); + const envWithIntent = touchesAnthropicModel + ? applySavedLongContextIntent(newEnv, extendedContextEnabled) + : newEnv; + delete envWithIntent['CCS_EXTENDED_CONTEXT']; + + const newSettings = { ...currentSettings, env: envWithIntent }; setRawJsonEdits(JSON.stringify(newSettings, null, 2)); }, - [currentSettings] + [applySavedLongContextIntent, currentSettings, extendedContextEnabled] ); // Check if JSON is valid diff --git a/ui/src/lib/extended-context-utils.ts b/ui/src/lib/extended-context-utils.ts index 3e09e846..2ce76d69 100644 --- a/ui/src/lib/extended-context-utils.ts +++ b/ui/src/lib/extended-context-utils.ts @@ -3,9 +3,16 @@ */ export { + ANTHROPIC_MODEL_ENV_KEYS, EXTENDED_CONTEXT_SUFFIX, + applyExtendedContextPreferenceToAnthropicModels, isNativeGeminiModel, + isAnthropicModelEnvKey, + hasAnthropicExtendedContextEnabled, hasExtendedContextSuffix, applyExtendedContextSuffix, + isClaudeModelId, + likelySupportsClaudeExtendedContext, + stripModelConfigurationSuffixes, stripExtendedContextSuffix, } from '../../../src/shared/extended-context-utils'; diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 9e711d72..8d9f64a2 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -4,6 +4,7 @@ */ import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; +import { stripModelConfigurationSuffixes } from '@/lib/extended-context-utils'; /** Model catalog data - mirrors src/cliproxy/model-catalog.ts */ export const MODEL_CATALOGS: Record = { @@ -553,3 +554,15 @@ export const MODEL_CATALOGS: Record = { ], }, }; + +export function findCatalogModel(provider: string, modelId: string) { + const catalog = MODEL_CATALOGS[provider.toLowerCase()]; + if (!catalog) return undefined; + + const normalizedModelId = stripModelConfigurationSuffixes(modelId); + return catalog.models.find((model) => model.id === normalizedModelId); +} + +export function supportsExtendedContext(provider: string, modelId: string): boolean { + return findCatalogModel(provider, modelId)?.extendedContext === true; +} From 5e11bba5149d061879002ad88ca91f404c09522a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Mar 2026 16:36:14 -0400 Subject: [PATCH 2/3] test(cliproxy): cover Claude long-context parity flows - add regression coverage for runtime, api create flags, and help text - add provider-editor tests for multi-key [1m] state and preset preservation Refs #789 --- .../cliproxy/extended-context-config.test.ts | 30 ++++ tests/unit/commands/api-command-args.test.ts | 76 +++++++++ .../unit/commands/help-command-parity.test.ts | 38 +++++ .../model-config-section.test.tsx | 7 +- .../use-provider-editor.test.tsx | 156 ++++++++++++++++++ 5 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts index 17e93a10..0de42ab0 100644 --- a/tests/unit/cliproxy/extended-context-config.test.ts +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -136,6 +136,22 @@ describe('applyExtendedContextConfig', () => { expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-5-20251101[1m]'); }); + it('applies explicit Claude [1m] per tier and leaves unsupported Haiku plain', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_MODEL).toBe('claude-haiku-4-5-20251001'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-20251101[1m]'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-20250929[1m]'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + }); + it('strips existing suffixes before catalog lookup', () => { const env: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gemini-2.5-pro(high)', @@ -169,6 +185,20 @@ describe('applyExtendedContextConfig', () => { expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); }); + it('strips stale Claude [1m] from unsupported mappings while preserving compatible ones', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929[1m]', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_MODEL).toBe('claude-haiku-4-5-20251001'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-20251101[1m]'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-20250929[1m]'); + }); + it('strips [1m] suffix when --no-1m is explicit even if model has it', () => { const env: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gemini-2.5-pro[1m]', diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 94032699..19727ec2 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { + applyClaudeExtendedContextPreference, collectUnexpectedApiArgs, + hasClaudeModelMapping, + hasExplicitClaudeExtendedContext, parseApiCommandArgs, } from '../../../src/commands/api-command/shared'; @@ -132,6 +135,27 @@ describe('api-command arg parser', () => { expect(parsed.model).toBe('-preview'); expect(parsed.errors).toEqual([]); }); + + test('parses --1m for explicit Claude long context', () => { + const parsed = parseApiCommandArgs(['my-api', '--1m']); + + expect(parsed.extendedContext).toBe(true); + expect(parsed.errors).toEqual([]); + }); + + test('parses --no-1m for explicit standard-context preference', () => { + const parsed = parseApiCommandArgs(['my-api', '--no-1m']); + + expect(parsed.extendedContext).toBe(false); + expect(parsed.errors).toEqual([]); + }); + + test('rejects conflicting --1m and --no-1m flags', () => { + const parsed = parseApiCommandArgs(['my-api', '--1m', '--no-1m']); + + expect(parsed.extendedContext).toBeUndefined(); + expect(parsed.errors).toEqual(['Cannot combine --1m and --no-1m']); + }); }); describe('collectUnexpectedApiArgs', () => { @@ -155,3 +179,55 @@ describe('collectUnexpectedApiArgs', () => { expect(parsed.errors).toEqual(['Unknown option: --bogus', 'Unexpected arguments: value']); }); }); + +describe('Claude long-context mapping helpers', () => { + test('detects Claude mappings and explicit [1m] state across all tiers', () => { + const models = { + default: 'gpt-5.4', + opus: 'claude-opus-4-6[1m]', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }; + + expect(hasClaudeModelMapping(models)).toBe(true); + expect(hasExplicitClaudeExtendedContext(models)).toBe(true); + }); + + test('applies [1m] only to compatible Claude mappings and leaves Haiku plain', () => { + const models = applyClaudeExtendedContextPreference( + { + default: 'claude-sonnet-4-6', + opus: 'claude-opus-4-6', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }, + true + ); + + expect(models).toEqual({ + default: 'claude-sonnet-4-6[1m]', + opus: 'claude-opus-4-6[1m]', + sonnet: 'claude-sonnet-4-6[1m]', + haiku: 'claude-haiku-4-5-20251001', + }); + }); + + test('strips [1m] from Claude mappings when standard context is requested', () => { + const models = applyClaudeExtendedContextPreference( + { + default: 'claude-sonnet-4-6[1m]', + opus: 'claude-opus-4-6[1m]', + sonnet: 'claude-sonnet-4-6[1m]', + haiku: 'claude-haiku-4-5-20251001', + }, + false + ); + + expect(models).toEqual({ + default: 'claude-sonnet-4-6', + opus: 'claude-opus-4-6', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }); + }); +}); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index e7afd2ce..451ea81a 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; +import { showApiCommandHelp } from '../../../src/commands/api-command/help'; import { handleHelpCommand } from '../../../src/commands/help-command'; function stripAnsi(input: string): string { @@ -103,4 +104,41 @@ describe('help command parity', () => { true ); }); + + test('root help explains Claude [1m] as an explicit CCS suffix with upstream limits', async () => { + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(' ')); + }; + + await handleHelpCommand(); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('Claude models: plain by default, opt-in with --1m or saved [1m]')).toBe( + true + ); + expect(rendered.includes('CCS only controls the saved [1m] suffix.')).toBe(true); + expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe( + true + ); + }); + + test('api help documents create-time Claude [1m] flags and entitlement warning', async () => { + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(' ')); + }; + + await showApiCommandHelp(); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('--1m / --no-1m')).toBe(true); + expect(rendered.includes('ccs api create --preset anthropic --1m')).toBe(true); + expect(rendered.includes('Plain Claude model IDs stay on standard context by default.')).toBe( + true + ); + expect(rendered.includes('some accounts can still return 429 for long-context requests')).toBe( + true + ); + }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx index 9fb5d916..ea31f449 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -26,6 +26,7 @@ describe('ModelConfigSection presets', () => { haikuModel="gpt-5-codex-mini" providerModels={[]} provider="codex" + onExtendedContextToggle={vi.fn()} onApplyPreset={onApplyPreset} onUpdateEnvValue={vi.fn()} onOpenCustomPreset={vi.fn()} @@ -55,10 +56,11 @@ describe('ModelConfigSection presets', () => { savedPresets={[]} currentModel="claude-opus-4-6-thinking" opusModel="claude-opus-4-6-thinking" - sonnetModel="claude-sonnet-4-6" - haikuModel="claude-sonnet-4-6" + sonnetModel="gemini-3-pro-preview" + haikuModel="gemini-3-flash-preview" providerModels={[]} provider="agy" + onExtendedContextToggle={vi.fn()} onApplyPreset={vi.fn()} onUpdateEnvValue={vi.fn()} onOpenCustomPreset={vi.fn()} @@ -69,5 +71,6 @@ describe('ModelConfigSection presets', () => { expect(screen.queryByText('Free Tier')).not.toBeInTheDocument(); expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument(); + expect(screen.getByTestId('extended-context-toggle')).toBeInTheDocument(); }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx new file mode 100644 index 00000000..31391e58 --- /dev/null +++ b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx @@ -0,0 +1,156 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AllProviders } from '../../../../setup/test-utils'; +import { useProviderEditor } from '@/components/cliproxy/provider-editor/use-provider-editor'; + +function createJsonResponse(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useProviderEditor', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('derives extended-context state from all Anthropic model env keys', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/claude/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'claude', + settings: { + env: { + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }, + mtime: 1, + path: '~/.ccs/profiles/claude/settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('claude'), { wrapper }); + + await waitFor(() => expect(result.current.currentModel).toBe('claude-sonnet-4-6')); + expect(result.current.extendedContextEnabled).toBe(true); + }); + + it('applies [1m] across compatible Claude mappings and leaves Haiku plain', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/claude/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'claude', + settings: { + env: { + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }, + mtime: 1, + path: '~/.ccs/profiles/claude/settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('claude'), { wrapper }); + + await waitFor(() => expect(result.current.currentModel).toBe('claude-sonnet-4-6')); + + act(() => { + result.current.toggleExtendedContext(true); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env).toMatchObject({ + ANTHROPIC_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); + + it('preserves explicit long-context intent when preset-style updates replace mappings', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/claude/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'claude', + settings: { + env: { + ANTHROPIC_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }, + mtime: 1, + path: '~/.ccs/profiles/claude/settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('claude'), { wrapper }); + + await waitFor(() => expect(result.current.extendedContextEnabled).toBe(true)); + + act(() => { + result.current.updateEnvValues({ + ANTHROPIC_MODEL: 'claude-opus-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env).toMatchObject({ + ANTHROPIC_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); +}); From be4ba13e9654f177a417a7956228653efe1a6210 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Mar 2026 16:56:34 -0400 Subject: [PATCH 3/3] fix(tests): stabilize command help CI validation --- src/commands/api-command/create-command.ts | 4 +- src/commands/api-command/help.ts | 160 +++-- src/commands/api-command/index.ts | 78 ++- src/commands/api-command/shared.ts | 7 +- src/commands/help-command.ts | 584 ++++++++++-------- .../unit/commands/api-command-router.test.ts | 72 +-- .../unit/commands/config-auth-command.test.ts | 18 +- .../unit/commands/help-command-parity.test.ts | 76 +-- 8 files changed, 528 insertions(+), 471 deletions(-) diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 9d9da93f..9583b8f8 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -491,7 +491,9 @@ export async function handleApiCreateCommand(args: string[]): Promise { if (hasClaudeMappings) { details += `\nLongCtx: ${ - shouldEnableClaudeLongContext ? 'compatible Claude mappings use [1m]' : 'standard Claude context' + shouldEnableClaudeLongContext + ? 'compatible Claude mappings use [1m]' + : 'standard Claude context' }`; } diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index b2fb7f94..37ddfccf 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -8,6 +8,8 @@ import { import { color, dim, fail, header, initUI, subheader } from '../../utils/ui'; import { sanitizeHelpText } from './shared'; +type HelpWriter = (line: string) => void; + function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { const presetId = sanitizeHelpText(preset.id) || 'unknown'; const paddedId = presetId.padEnd(idWidth); @@ -16,7 +18,7 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`; } -export async function showApiCommandHelp(): Promise { +export async function showApiCommandHelp(writeLine: HelpWriter = console.log): Promise { await initUI(); const presetIds = getPresetIds() .map((id) => sanitizeHelpText(id)) @@ -25,112 +27,108 @@ export async function showApiCommandHelp(): Promise { const presetAliases = getPresetAliases(); const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2; - console.log(header('CCS API Management')); - console.log(''); - console.log(subheader('Usage')); - console.log(` ${color('ccs api', 'command')} [options]`); - console.log(''); - console.log(subheader('Commands')); - console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`); - console.log(` ${color('list', 'command')} List all API profiles`); - console.log( + writeLine(header('CCS API Management')); + writeLine(''); + writeLine(subheader('Usage')); + writeLine(` ${color('ccs api', 'command')} [options]`); + writeLine(''); + writeLine(subheader('Commands')); + writeLine(` ${color('create [name]', 'command')} Create new API profile (interactive)`); + writeLine(` ${color('list', 'command')} List all API profiles`); + writeLine( ` ${color('discover', 'command')} Discover orphan *.settings.json and register` ); - console.log(` ${color('copy ', 'command')} Duplicate API profile settings + config`); - console.log( + writeLine(` ${color('copy ', 'command')} Duplicate API profile settings + config`); + writeLine( ` ${color('export ', 'command')} Export profile bundle for cross-device transfer` ); - console.log( - ` ${color('import ', 'command')} Import profile bundle and register profile` - ); - console.log(` ${color('remove ', 'command')} Remove an API profile`); - console.log(''); - console.log(subheader('Options')); - console.log( + writeLine(` ${color('import ', 'command')} Import profile bundle and register profile`); + writeLine(` ${color('remove ', 'command')} Remove an API profile`); + writeLine(''); + writeLine(subheader('Options')); + writeLine( ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` ); - console.log( + writeLine( ` ${color('--cliproxy-provider ', 'command')} Use routed CLIProxy provider (${cliproxyProviderIds.join(', ')})` ); - console.log(` ${color('--base-url ', 'command')} API base URL (create)`); - console.log(` ${color('--api-key ', 'command')} API key (create)`); - console.log(` ${color('--model ', 'command')} Default model (create)`); - console.log( + writeLine(` ${color('--base-url ', 'command')} API base URL (create)`); + writeLine(` ${color('--api-key ', 'command')} API key (create)`); + writeLine(` ${color('--model ', 'command')} Default model (create)`); + writeLine( ` ${color('--1m / --no-1m', 'command')} Write or clear [1m] on compatible Claude mappings` ); - console.log( + writeLine( ` ${color('--target ', 'command')} Default target: claude or droid (create)` ); - console.log(` ${color('--register', 'command')} Register discovered orphan settings`); - console.log(` ${color('--json', 'command')} JSON output for discover command`); - console.log(` ${color('--out ', 'command')} Export bundle output path`); - console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`); - console.log(` ${color('--name ', 'command')} Override profile name during import`); - console.log( + writeLine(` ${color('--register', 'command')} Register discovered orphan settings`); + writeLine(` ${color('--json', 'command')} JSON output for discover command`); + writeLine(` ${color('--out ', 'command')} Export bundle output path`); + writeLine(` ${color('--include-secrets', 'command')} Include token in export bundle`); + writeLine(` ${color('--name ', 'command')} Override profile name during import`); + writeLine( ` ${color('--force', 'command')} Overwrite existing or bypass validation (create/discover/copy/import)` ); - console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); - console.log(''); - console.log(subheader('Provider Presets')); - PROVIDER_PRESETS.forEach((preset) => console.log(renderPresetHelpLine(preset, presetIdWidth))); + writeLine(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); + writeLine(''); + writeLine(subheader('Provider Presets')); + PROVIDER_PRESETS.forEach((preset) => writeLine(renderPresetHelpLine(preset, presetIdWidth))); Object.entries(presetAliases).forEach(([alias, canonical]) => { const safeAlias = sanitizeHelpText(alias); const safeCanonical = sanitizeHelpText(canonical); - console.log( - ` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}` - ); + writeLine(` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}`); }); - console.log(''); - console.log(subheader('Examples')); - console.log(` ${dim('# Interactive wizard')}`); - console.log(` ${color('ccs api create', 'command')}`); - console.log(''); - console.log(` ${dim('# Quick setup with preset')}`); - console.log(` ${color('ccs api create --preset anthropic', 'command')}`); - console.log( + writeLine(''); + writeLine(subheader('Examples')); + writeLine(` ${dim('# Interactive wizard')}`); + writeLine(` ${color('ccs api create', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Quick setup with preset')}`); + writeLine(` ${color('ccs api create --preset anthropic', 'command')}`); + writeLine( ` ${color('ccs api create --preset anthropic --1m', 'command')} ${dim('# explicit Claude [1m] opt-in')}` ); - console.log(` ${color('ccs api create --preset openrouter', 'command')}`); - console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); - console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); - console.log(` ${color('ccs api create --preset glm', 'command')}`); - console.log(''); - console.log(subheader('Claude Long Context')); - console.log(` ${dim('Plain Claude model IDs stay on standard context by default.')}`); - console.log( + writeLine(` ${color('ccs api create --preset openrouter', 'command')}`); + writeLine(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); + writeLine(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); + writeLine(` ${color('ccs api create --preset glm', 'command')}`); + writeLine(''); + writeLine(subheader('Claude Long Context')); + writeLine(` ${dim('Plain Claude model IDs stay on standard context by default.')}`); + writeLine( ` ${dim('Use --1m during create to append [1m] to compatible Claude mappings, or --no-1m to force plain IDs.')}` ); - console.log( + writeLine( ` ${dim('CCS controls only the saved [1m] suffix. Provider pricing/entitlement stay upstream, and some accounts can still return 429 for long-context requests.')}` ); - console.log(''); - console.log(` ${dim('# Create routed profile from existing CLIProxy provider config')}`); - console.log(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`); - console.log( + writeLine(''); + writeLine(` ${dim('# Create routed profile from existing CLIProxy provider config')}`); + writeLine(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`); + writeLine( ` ${color('ccs api create gemini-droid --cliproxy-provider gemini --target droid', 'command')}` ); - console.log(''); - console.log(` ${dim('# Create with name')}`); - console.log(` ${color('ccs api create myapi', 'command')}`); - console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); - console.log(''); - console.log(` ${dim('# Remove API profile')}`); - console.log(` ${color('ccs api remove myapi', 'command')}`); - console.log(''); - console.log(` ${dim('# Discover and register orphan settings files')}`); - console.log(` ${color('ccs api discover', 'command')}`); - console.log(` ${color('ccs api discover --register', 'command')}`); - console.log(''); - console.log(` ${dim('# Duplicate an existing API profile')}`); - console.log(` ${color('ccs api copy glm glm-backup', 'command')}`); - console.log(''); - console.log(` ${dim('# Export and import across devices')}`); - console.log(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`); - console.log(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`); - console.log(''); - console.log(` ${dim('# Show all API profiles')}`); - console.log(` ${color('ccs api list', 'command')}`); - console.log(''); + writeLine(''); + writeLine(` ${dim('# Create with name')}`); + writeLine(` ${color('ccs api create myapi', 'command')}`); + writeLine(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Remove API profile')}`); + writeLine(` ${color('ccs api remove myapi', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Discover and register orphan settings files')}`); + writeLine(` ${color('ccs api discover', 'command')}`); + writeLine(` ${color('ccs api discover --register', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Duplicate an existing API profile')}`); + writeLine(` ${color('ccs api copy glm glm-backup', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Export and import across devices')}`); + writeLine(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`); + writeLine(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Show all API profiles')}`); + writeLine(` ${color('ccs api list', 'command')}`); + writeLine(''); } export async function showUnknownApiCommand(command: string): Promise { diff --git a/src/commands/api-command/index.ts b/src/commands/api-command/index.ts index 79e6b560..2d9fe526 100644 --- a/src/commands/api-command/index.ts +++ b/src/commands/api-command/index.ts @@ -10,22 +10,68 @@ import { handleApiRemoveCommand } from './remove-command'; export { parseApiCommandArgs } from './shared'; -const API_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ - { name: 'create', handle: handleApiCreateCommand }, - { name: 'list', handle: handleApiListCommand }, - { name: 'discover', handle: handleApiDiscoverCommand }, - { name: 'copy', handle: handleApiCopyCommand }, - { name: 'export', handle: handleApiExportCommand }, - { name: 'import', handle: handleApiImportCommand }, - { name: 'remove', aliases: ['delete', 'rm'], handle: handleApiRemoveCommand }, -]; +type ApiCommandHandler = (args: string[]) => Promise; +type ApiCommandHelpHandler = () => Promise; +type ApiCommandUnknownHandler = (command: string) => Promise; + +export interface ApiCommandDependencies { + help: ApiCommandHelpHandler; + unknown: ApiCommandUnknownHandler; + create: ApiCommandHandler; + list: ApiCommandHandler; + discover: ApiCommandHandler; + copy: ApiCommandHandler; + export: ApiCommandHandler; + import: ApiCommandHandler; + remove: ApiCommandHandler; +} + +const DEFAULT_API_COMMAND_DEPENDENCIES: ApiCommandDependencies = { + help: showApiCommandHelp, + unknown: showUnknownApiCommand, + create: handleApiCreateCommand, + list: handleApiListCommand, + discover: handleApiDiscoverCommand, + copy: handleApiCopyCommand, + export: handleApiExportCommand, + import: handleApiImportCommand, + remove: handleApiRemoveCommand, +}; + +function createApiCommandRoutes( + dependencies: ApiCommandDependencies +): readonly NamedCommandRoute[] { + return [ + { name: 'create', handle: dependencies.create }, + { name: 'list', handle: dependencies.list }, + { name: 'discover', handle: dependencies.discover }, + { name: 'copy', handle: dependencies.copy }, + { name: 'export', handle: dependencies.export }, + { name: 'import', handle: dependencies.import }, + { name: 'remove', aliases: ['delete', 'rm'], handle: dependencies.remove }, + ]; +} + +export function createApiCommandHandler( + overrides: Partial = {} +): (args: string[]) => Promise { + const dependencies: ApiCommandDependencies = { + ...DEFAULT_API_COMMAND_DEPENDENCIES, + ...overrides, + }; + const routes = createApiCommandRoutes(dependencies); + + return async (args: string[]) => { + await dispatchNamedCommand({ + args, + routes, + onHelp: dependencies.help, + onUnknown: dependencies.unknown, + allowEmptyHelp: true, + }); + }; +} export async function handleApiCommand(args: string[]): Promise { - await dispatchNamedCommand({ - args, - routes: API_COMMAND_ROUTES, - onHelp: showApiCommandHelp, - onUnknown: showUnknownApiCommand, - allowEmptyHelp: true, - }); + await createApiCommandHandler()(args); } diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index 2e725dd0..06ae02c1 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -294,9 +294,10 @@ export function applyClaudeExtendedContextPreference( continue; } - nextModels[key] = enabled && likelySupportsClaudeExtendedContext(value) - ? applyExtendedContextSuffix(value) - : stripExtendedContextSuffix(value); + nextModels[key] = + enabled && likelySupportsClaudeExtendedContext(value) + ? applyExtendedContextSuffix(value) + : stripExtendedContextSuffix(value); } return nextModels; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 80e203a8..e3bef4e6 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -5,6 +5,8 @@ import { isUnifiedMode } from '../config/unified-config-loader'; import { getCcsDir, getCcsDirSource } from '../utils/config-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; +type HelpWriter = (line: string) => void; + // Get version from package.json (same as version-command.ts) const VERSION = JSON.parse( fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8') @@ -19,28 +21,33 @@ const VERSION = JSON.parse( * * command Description */ -function printMajorSection(title: string, subtitles: string[], items: [string, string][]): void { +function printMajorSection( + title: string, + subtitles: string[], + items: [string, string][], + writeLine: HelpWriter = console.log +): void { // Section header with ═══ borders - console.log(sectionHeader(title)); + writeLine(sectionHeader(title)); // Subtitles on separate lines (dim) for (const subtitle of subtitles) { - console.log(` ${dim(subtitle)}`); + writeLine(` ${dim(subtitle)}`); } // Empty line before items - console.log(''); + writeLine(''); // Calculate max command length for alignment const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length)); for (const [cmd, desc] of items) { const paddedCmd = cmd.padEnd(maxCmdLen + 2); - console.log(` ${color(paddedCmd, 'command')} ${desc}`); + writeLine(` ${color(paddedCmd, 'command')} ${desc}`); } // Extra spacing after section - console.log(''); + writeLine(''); } /** @@ -49,20 +56,24 @@ function printMajorSection(title: string, subtitles: string[], items: [string, s * Title (context): * command Description */ -function printSubSection(title: string, items: [string, string][]): void { +function printSubSection( + title: string, + items: [string, string][], + writeLine: HelpWriter = console.log +): void { // Sub-section header (colored, no borders) - console.log(subheader(`${title}:`)); + writeLine(subheader(`${title}:`)); // Calculate max command length for alignment const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length)); for (const [cmd, desc] of items) { const paddedCmd = cmd.padEnd(maxCmdLen + 2); - console.log(` ${color(paddedCmd, 'command')} ${desc}`); + writeLine(` ${color(paddedCmd, 'command')} ${desc}`); } // Spacing after section - console.log(''); + writeLine(''); } /** @@ -71,24 +82,28 @@ function printSubSection(title: string, items: [string, string][]): void { * Title: * Label: path */ -function printConfigSection(title: string, items: [string, string][]): void { - console.log(subheader(`${title}:`)); +function printConfigSection( + title: string, + items: [string, string][], + writeLine: HelpWriter = console.log +): void { + writeLine(subheader(`${title}:`)); // Calculate max label length for alignment const maxLabelLen = Math.max(...items.map(([label]) => label.length)); for (const [label, path] of items) { const paddedLabel = label.padEnd(maxLabelLen); - console.log(` ${paddedLabel} ${color(path, 'path')}`); + writeLine(` ${paddedLabel} ${color(path, 'path')}`); } - console.log(''); + writeLine(''); } /** * Display comprehensive help information for CCS (Claude Code Switch) */ -export async function handleHelpCommand(): Promise { +export async function handleHelpCommand(writeLine: HelpWriter = console.log): Promise { // Initialize UI (if not already) await initUI(); @@ -103,24 +118,24 @@ Claude Code Profile & Model Switcher Run ${color('ccs config', 'command')} for web dashboard`.trim(); - console.log( + writeLine( box(logo, { padding: 1, borderStyle: 'round', titleAlignment: 'center', }) ); - console.log(''); + writeLine(''); // Resolve display path for dynamic sections const [dirSource] = getCcsDirSource(); const dirDisplay = dirSource === 'default' ? '~/.ccs' : getCcsDir(); // Usage section - console.log(subheader('Usage:')); - console.log(` ${color('ccs', 'command')} [profile] [claude-args...]`); - console.log(` ${color('ccs', 'command')} [flags]`); - console.log(''); + writeLine(subheader('Usage:')); + writeLine(` ${color('ccs', 'command')} [profile] [claude-args...]`); + writeLine(` ${color('ccs', 'command')} [flags]`); + writeLine(''); // ═══════════════════════════════════════════════════════════════════════════ // MAJOR SECTION 1: API Key Profiles @@ -152,7 +167,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs api import ', 'Import profile bundle'], ['ccs api remove', 'Remove an API profile'], ['ccs api list', 'List all API profiles'], - ] + ], + writeLine ); // ═══════════════════════════════════════════════════════════════════════════ @@ -176,7 +192,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs auth default ', 'Set default profile'], ['ccs auth reset-default', 'Restore original CCS default'], ['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'], - ] + ], + writeLine ); // ═══════════════════════════════════════════════════════════════════════════ @@ -232,7 +249,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs kiro --import', 'Import token from Kiro IDE'], ['ccs kiro --incognito', 'Use incognito browser (default: normal)'], ['ccs codex "explain code"', 'Use with prompt'], - ] + ], + writeLine ); // ═══════════════════════════════════════════════════════════════════════════ @@ -255,7 +273,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs copilot stop', 'Stop copilot-api daemon'], ['ccs copilot enable', 'Enable integration'], ['ccs copilot disable', 'Disable integration'], - ] + ], + writeLine ); // ═══════════════════════════════════════════════════════════════════════════ @@ -277,7 +296,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs cursor stop', 'Stop proxy daemon'], ['ccs cursor enable', 'Enable cursor integration'], ['ccs cursor disable', 'Disable cursor integration'], - ] + ], + writeLine ); // ═══════════════════════════════════════════════════════════════════════════ @@ -285,281 +305,351 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // ═══════════════════════════════════════════════════════════════════════════ // Delegation - printSubSection('Delegation (inside Claude Code CLI)', [ - ['/ccs "task"', 'Delegate task (auto-selects profile)'], - ['/ccs --glm "task"', 'Force GLM-5 for simple tasks'], - ['/ccs --kimi "task"', 'Force Kimi OAuth for long context'], - ['/ccs --km "task"', 'Force Kimi API key for long context'], - ['/ccs:continue "follow-up"', 'Continue last delegation session'], - ]); + printSubSection( + 'Delegation (inside Claude Code CLI)', + [ + ['/ccs "task"', 'Delegate task (auto-selects profile)'], + ['/ccs --glm "task"', 'Force GLM-5 for simple tasks'], + ['/ccs --kimi "task"', 'Force Kimi OAuth for long context'], + ['/ccs --km "task"', 'Force Kimi API key for long context'], + ['/ccs:continue "follow-up"', 'Continue last delegation session'], + ], + writeLine + ); // Delegation CLI Flags (Claude Code passthrough) - printSubSection('Delegation Flags (Claude Code passthrough)', [ - ['--max-turns ', 'Limit agentic turns (prevents loops)'], - ['--fallback-model ', 'Auto-fallback on overload (sonnet)'], - ['--agents ', 'Inject dynamic subagents'], - ['--betas ', 'Enable experimental features'], - ['--allowedTools ', 'Restrict available tools'], - ['--disallowedTools ', 'Block specific tools'], - ]); + printSubSection( + 'Delegation Flags (Claude Code passthrough)', + [ + ['--max-turns ', 'Limit agentic turns (prevents loops)'], + ['--fallback-model ', 'Auto-fallback on overload (sonnet)'], + ['--agents ', 'Inject dynamic subagents'], + ['--betas ', 'Enable experimental features'], + ['--allowedTools ', 'Restrict available tools'], + ['--disallowedTools ', 'Block specific tools'], + ], + writeLine + ); // Diagnostics - printSubSection('Diagnostics', [ - ['ccs setup', 'First-time setup wizard'], - ['ccs doctor', 'Run health check and diagnostics'], - ['ccs cleanup', 'Remove old CLIProxy logs'], - ['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'], - ['ccs config auth setup', 'Configure dashboard login'], - ['ccs config auth show', 'Show dashboard auth status'], - ['ccs config channels', 'Show Official Channels status'], + printSubSection( + 'Diagnostics', [ - 'ccs config channels --set telegram,discord', - 'Auto-add Telegram + Discord on supported native Claude runs', + ['ccs setup', 'First-time setup wizard'], + ['ccs doctor', 'Run health check and diagnostics'], + ['ccs cleanup', 'Remove old CLIProxy logs'], + ['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'], + ['ccs config auth setup', 'Configure dashboard login'], + ['ccs config auth show', 'Show dashboard auth status'], + ['ccs config channels', 'Show Official Channels status'], + [ + 'ccs config channels --set telegram,discord', + 'Auto-add Telegram + Discord on supported native Claude runs', + ], + ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], + ['ccs config image-analysis', 'Show image analysis settings'], + ['ccs config image-analysis --enable', 'Enable image analysis'], + ['ccs config thinking', 'Show thinking/reasoning settings'], + ['ccs config thinking --mode auto', 'Set thinking mode'], + ['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'], + ['ccs config --port 3000', 'Use specific port'], + ['ccs config --host 0.0.0.0', 'Force all-interface binding for remote devices'], + ['ccs persist ', 'Write profile setup to ~/.claude/settings.json'], + ['ccs persist --list-backups', 'List available settings.json backups'], + ['ccs persist --restore', 'Restore settings.json from latest backup'], + ['ccs sync', 'Sync delegation commands and skills'], + ['ccs update', 'Update CCS to latest version'], + ['ccs update --force', 'Force reinstall current version'], + ['ccs update --beta', 'Install from dev channel (unstable)'], ], - ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], - ['ccs config image-analysis', 'Show image analysis settings'], - ['ccs config image-analysis --enable', 'Enable image analysis'], - ['ccs config thinking', 'Show thinking/reasoning settings'], - ['ccs config thinking --mode auto', 'Set thinking mode'], - ['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'], - ['ccs config --port 3000', 'Use specific port'], - ['ccs config --host 0.0.0.0', 'Force all-interface binding for remote devices'], - ['ccs persist ', 'Write profile setup to ~/.claude/settings.json'], - ['ccs persist --list-backups', 'List available settings.json backups'], - ['ccs persist --restore', 'Restore settings.json from latest backup'], - ['ccs sync', 'Sync delegation commands and skills'], - ['ccs update', 'Update CCS to latest version'], - ['ccs update --force', 'Force reinstall current version'], - ['ccs update --beta', 'Install from dev channel (unstable)'], - ]); + writeLine + ); // Environment export - printSubSection('Environment Export', [ - ['ccs env ', 'Export env vars for third-party tools'], - ['ccs env --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'], - ['ccs env --format anthropic', 'Anthropic vars (default)'], - ['ccs env --format raw', 'All effective env vars'], + printSubSection( + 'Environment Export', [ - 'ccs env --format claude-extension --ide vscode', - 'VS Code/Cursor Claude extension settings JSON', + ['ccs env ', 'Export env vars for third-party tools'], + ['ccs env --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'], + ['ccs env --format anthropic', 'Anthropic vars (default)'], + ['ccs env --format raw', 'All effective env vars'], + [ + 'ccs env --format claude-extension --ide vscode', + 'VS Code/Cursor Claude extension settings JSON', + ], + [ + 'ccs env --format claude-extension --ide windsurf', + 'Windsurf Claude extension settings JSON', + ], + ['ccs env --shell fish', 'Fish shell syntax'], ], - [ - 'ccs env --format claude-extension --ide windsurf', - 'Windsurf Claude extension settings JSON', - ], - ['ccs env --shell fish', 'Fish shell syntax'], - ]); + writeLine + ); // Flags - printSubSection('Flags', [ - ['--config-dir ', 'Use custom CCS config directory'], - ['--target ', 'Target CLI: claude (default), droid'], - ['-h, --help', 'Show this help message'], - ['-v, --version', 'Show version and installation info'], - ['-sc, --shell-completion', 'Install shell auto-completion'], - ]); + printSubSection( + 'Flags', + [ + ['--config-dir ', 'Use custom CCS config directory'], + ['--target ', 'Target CLI: claude (default), droid'], + ['-h, --help', 'Show this help message'], + ['-v, --version', 'Show version and installation info'], + ['-sc, --shell-completion', 'Install shell auto-completion'], + ], + writeLine + ); // Aliases - printSubSection('Aliases', [ - ['ccs-droid [args]', 'Explicit Droid runtime alias'], - ['ccsd [args]', 'Legacy shortcut for: ccs-droid [args]'], - ]); + printSubSection( + 'Aliases', + [ + ['ccs-droid [args]', 'Explicit Droid runtime alias'], + ['ccsd [args]', 'Legacy shortcut for: ccs-droid [args]'], + ], + writeLine + ); // Multi-target examples - printSubSection('Multi-Target', [ - ['ccs glm --target droid', 'Run GLM profile on Droid CLI'], - ['ccs-droid glm', 'Same as above (explicit alias)'], - ['ccsd glm', 'Legacy shortcut for ccs-droid'], - ['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'], - ['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'], + printSubSection( + 'Multi-Target', [ - 'ccs-droid codex exec --skip-permissions-unsafe "fix failing tests"', - 'Pass through Droid exec mode', + ['ccs glm --target droid', 'Run GLM profile on Droid CLI'], + ['ccs-droid glm', 'Same as above (explicit alias)'], + ['ccsd glm', 'Legacy shortcut for ccs-droid'], + ['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'], + ['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'], + [ + 'ccs-droid codex exec --skip-permissions-unsafe "fix failing tests"', + 'Pass through Droid exec mode', + ], + [ + 'ccs-droid codex -m custom:gpt-5.3-codex "fix failing tests"', + 'Auto-routes short exec flags', + ], + [ + 'ccs-droid codex --skip-permissions-unsafe "fix failing tests"', + 'Auto-routes to Droid exec when exec-only flags are detected', + ], + [ + 'ccs cliproxy create my-codex --provider codex --target droid', + 'Create CLIProxy variant with Droid as default target', + ], + ['ccs glm', 'Run GLM profile on Claude Code (default)'], ], - ['ccs-droid codex -m custom:gpt-5.3-codex "fix failing tests"', 'Auto-routes short exec flags'], - [ - 'ccs-droid codex --skip-permissions-unsafe "fix failing tests"', - 'Auto-routes to Droid exec when exec-only flags are detected', - ], - [ - 'ccs cliproxy create my-codex --provider codex --target droid', - 'Create CLIProxy variant with Droid as default target', - ], - ['ccs glm', 'Run GLM profile on Claude Code (default)'], - ]); + writeLine + ); // Configuration - printConfigSection('Configuration', [ - ['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`], - ['Profiles:', `${dirDisplay}/profiles.json`], - ['Instances:', `${dirDisplay}/instances/`], - ['Settings:', `${dirDisplay}/*.settings.json`], - ]); + printConfigSection( + 'Configuration', + [ + ['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`], + ['Profiles:', `${dirDisplay}/profiles.json`], + ['Instances:', `${dirDisplay}/instances/`], + ['Settings:', `${dirDisplay}/*.settings.json`], + ], + writeLine + ); // CLI Proxy management - printSubSection('CLI Proxy Plus Management', [ - ['ccs cliproxy', 'Show CLIProxy Plus status and version'], - ['ccs cliproxy --help', 'Full CLIProxy Plus management help'], - ['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'], - ['ccs cliproxy --install ', 'Install specific version (e.g., 6.6.6)'], - ['ccs cliproxy --latest', 'Update to latest version'], - ['', ''], // Spacer - ['ccs cliproxy pause

', 'Pause account from rotation'], - ['ccs cliproxy resume

', 'Resume paused account'], - ['ccs cliproxy status', 'Show CLIProxy process status'], - ['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'], - ['ccs cliproxy quota --provider ', 'Show quota/tier/pause status for one provider'], - ]); + printSubSection( + 'CLI Proxy Plus Management', + [ + ['ccs cliproxy', 'Show CLIProxy Plus status and version'], + ['ccs cliproxy --help', 'Full CLIProxy Plus management help'], + ['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'], + ['ccs cliproxy --install ', 'Install specific version (e.g., 6.6.6)'], + ['ccs cliproxy --latest', 'Update to latest version'], + ['', ''], // Spacer + ['ccs cliproxy pause

', 'Pause account from rotation'], + ['ccs cliproxy resume

', 'Resume paused account'], + ['ccs cliproxy status', 'Show CLIProxy process status'], + ['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'], + ['ccs cliproxy quota --provider ', 'Show quota/tier/pause status for one provider'], + ], + writeLine + ); // CLI Proxy configuration flags (new) - printSubSection('CLI Proxy Configuration', [ - ['--proxy-host ', 'Remote proxy hostname/IP'], - ['--proxy-port ', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`], - ['--proxy-protocol ', 'Protocol: http or https (default: http)'], - ['--proxy-auth-token ', 'Auth token for remote proxy'], - ['--proxy-timeout ', 'Connection timeout in ms (default: 2000)'], - ['--local-proxy', 'Force local mode, ignore remote config'], - ['--remote-only', 'Fail if remote unreachable (no fallback)'], - ['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'], - ]); + printSubSection( + 'CLI Proxy Configuration', + [ + ['--proxy-host ', 'Remote proxy hostname/IP'], + ['--proxy-port ', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`], + ['--proxy-protocol ', 'Protocol: http or https (default: http)'], + ['--proxy-auth-token ', 'Auth token for remote proxy'], + ['--proxy-timeout ', 'Connection timeout in ms (default: 2000)'], + ['--local-proxy', 'Force local mode, ignore remote config'], + ['--remote-only', 'Fail if remote unreachable (no fallback)'], + ['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'], + ], + writeLine + ); // W3: Thinking Budget explanation - printSubSection('Extended Thinking / Reasoning', [ - ['--thinking off', 'Disable extended thinking'], - ['--thinking auto', 'Let model decide dynamically'], - ['--thinking low', '1K tokens - Quick responses'], - ['--thinking medium', '8K tokens - Standard analysis'], - ['--thinking high', '24K tokens - Deep reasoning'], - ['--thinking xhigh', '32K tokens - Maximum depth'], - ['--thinking ', 'Custom token budget (512-100000)'], - ['', ''], - ['--effort ', 'Codex alias for reasoning effort (medium/high/xhigh)'], - ['--effort xhigh', 'Pin Codex effort to xhigh for this run'], - ['', ''], - ['Droid exec:', 'Use native Droid flag: --reasoning-effort '], - ['', 'CCS auto-maps --thinking/--effort to --reasoning-effort in droid exec mode.'], - ['', 'For interactive droid sessions, CCS applies reasoning via Droid BYOK model config.'], - ['', 'When multiple reasoning flags are provided, the first flag wins.'], - ['', ''], - ['Note:', 'Extended thinking allocates compute for step-by-step reasoning'], - ['', 'before responding.'], - ['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'], - ['', 'Codex model suffixes also pin effort: -medium / -high / -xhigh.'], - ]); + printSubSection( + 'Extended Thinking / Reasoning', + [ + ['--thinking off', 'Disable extended thinking'], + ['--thinking auto', 'Let model decide dynamically'], + ['--thinking low', '1K tokens - Quick responses'], + ['--thinking medium', '8K tokens - Standard analysis'], + ['--thinking high', '24K tokens - Deep reasoning'], + ['--thinking xhigh', '32K tokens - Maximum depth'], + ['--thinking ', 'Custom token budget (512-100000)'], + ['', ''], + ['--effort ', 'Codex alias for reasoning effort (medium/high/xhigh)'], + ['--effort xhigh', 'Pin Codex effort to xhigh for this run'], + ['', ''], + ['Droid exec:', 'Use native Droid flag: --reasoning-effort '], + ['', 'CCS auto-maps --thinking/--effort to --reasoning-effort in droid exec mode.'], + ['', 'For interactive droid sessions, CCS applies reasoning via Droid BYOK model config.'], + ['', 'When multiple reasoning flags are provided, the first flag wins.'], + ['', ''], + ['Note:', 'Extended thinking allocates compute for step-by-step reasoning'], + ['', 'before responding.'], + ['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'], + ['', 'Codex model suffixes also pin effort: -medium / -high / -xhigh.'], + ], + writeLine + ); // Extended Context (1M) - printSubSection('Extended Context (--1m)', [ - ['--1m', 'Request 1M token context when the selected model supports [1m]'], - ['--no-1m', 'Force standard context (Claude default stays plain)'], - ['', ''], - ['Auto behavior:', 'Gemini models: CCS auto-adds [1m] when supported'], - ['', 'Claude models: plain by default, opt-in with --1m or saved [1m]'], - ['', ''], - ['Note:', 'CCS only controls the saved [1m] suffix.'], - ['', 'Provider pricing and entitlement stay upstream.'], - ['', 'Some accounts/providers can still return 429 extra-usage errors for long-context requests.'], - ]); + printSubSection( + 'Extended Context (--1m)', + [ + ['--1m', 'Request 1M token context when the selected model supports [1m]'], + ['--no-1m', 'Force standard context (Claude default stays plain)'], + ['', ''], + ['Auto behavior:', 'Gemini models: CCS auto-adds [1m] when supported'], + ['', 'Claude models: plain by default, opt-in with --1m or saved [1m]'], + ['', ''], + ['Note:', 'CCS only controls the saved [1m] suffix.'], + ['', 'Provider pricing and entitlement stay upstream.'], + [ + '', + 'Some accounts/providers can still return 429 extra-usage errors for long-context requests.', + ], + ], + writeLine + ); // Image Analysis - printSubSection('Image Analysis (CLIProxy vision)', [ - ['ccs config image-analysis', 'Show current settings'], - ['ccs config image-analysis --enable', 'Enable for CLIProxy providers'], - ['ccs config image-analysis --disable', 'Disable (use native Read)'], - ['ccs config image-analysis --timeout 120', 'Set analysis timeout'], - ['ccs config image-analysis --set-model

', 'Set provider model'], - ['', ''], - ['Note:', 'When enabled, images/PDFs are analyzed via vision models'], - ['', 'instead of passing raw data to Claude. Works with CLIProxy'], - ['', 'providers (agy, gemini, codex, kiro, ghcp).'], - ]); - - printSubSection('Official Channels (official Claude plugins)', [ - ['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'], - ['ccs config channels', 'Show current status'], + printSubSection( + 'Image Analysis (CLIProxy vision)', [ - 'ccs config channels --set telegram,discord', - 'Auto-add selected channels on native Claude default/account sessions', + ['ccs config image-analysis', 'Show current settings'], + ['ccs config image-analysis --enable', 'Enable for CLIProxy providers'], + ['ccs config image-analysis --disable', 'Disable (use native Read)'], + ['ccs config image-analysis --timeout 120', 'Set analysis timeout'], + ['ccs config image-analysis --set-model

', 'Set provider model'], + ['', ''], + ['Note:', 'When enabled, images/PDFs are analyzed via vision models'], + ['', 'instead of passing raw data to Claude. Works with CLIProxy'], + ['', 'providers (agy, gemini, codex, kiro, ghcp).'], ], - ['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'], - ['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], - ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], - ['ccs config channels --set-token discord=', 'Save DISCORD_BOT_TOKEN'], - ['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'], - ['', ''], - ['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'], - ['Note:', 'Runtime-only. Applies to native Claude default/account sessions only.'], - ['', 'Not supported for ccs glm, other API/OAuth profiles, or Droid targets.'], - ['', 'Telegram/Discord tokens live in ~/.claude/channels//.env.'], - ['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'], - ['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'], - ]); + writeLine + ); + + printSubSection( + 'Official Channels (official Claude plugins)', + [ + ['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'], + ['ccs config channels', 'Show current status'], + [ + 'ccs config channels --set telegram,discord', + 'Auto-add selected channels on native Claude default/account sessions', + ], + ['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'], + ['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], + ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], + ['ccs config channels --set-token discord=', 'Save DISCORD_BOT_TOKEN'], + ['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'], + ['', ''], + ['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'], + ['Note:', 'Runtime-only. Applies to native Claude default/account sessions only.'], + ['', 'Not supported for ccs glm, other API/OAuth profiles, or Droid targets.'], + ['', 'Telegram/Discord tokens live in ~/.claude/channels//.env.'], + ['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'], + ['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'], + ], + writeLine + ); // CCS Environment Variables - printSubSection('Environment Variables', [ - ['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'], - ['CCS_HOME', 'Override home directory (legacy, appends .ccs)'], - ['CCS_DEBUG', 'Enable debug logging'], - ['CCS_THINKING', 'Override thinking level (flag > env > config)'], - ]); + printSubSection( + 'Environment Variables', + [ + ['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'], + ['CCS_HOME', 'Override home directory (legacy, appends .ccs)'], + ['CCS_DEBUG', 'Enable debug logging'], + ['CCS_THINKING', 'Override thinking level (flag > env > config)'], + ], + writeLine + ); // CLI Proxy env vars - printSubSection('CLI Proxy Environment Variables', [ - ['CCS_PROXY_HOST', 'Remote proxy hostname'], - ['CCS_PROXY_PORT', 'Proxy port'], - ['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'], - ['CCS_PROXY_AUTH_TOKEN', 'Auth token'], - ['CCS_PROXY_TIMEOUT', 'Connection timeout in ms'], - ['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'], - ['CCS_ALLOW_SELF_SIGNED', 'Allow self-signed certs (1/0)'], - ]); + printSubSection( + 'CLI Proxy Environment Variables', + [ + ['CCS_PROXY_HOST', 'Remote proxy hostname'], + ['CCS_PROXY_PORT', 'Proxy port'], + ['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'], + ['CCS_PROXY_AUTH_TOKEN', 'Auth token'], + ['CCS_PROXY_TIMEOUT', 'Connection timeout in ms'], + ['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'], + ['CCS_ALLOW_SELF_SIGNED', 'Allow self-signed certs (1/0)'], + ], + writeLine + ); // CLI Proxy paths - console.log(subheader('CLI Proxy:')); - console.log(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`); - console.log(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`); - console.log(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`); - console.log(` ${dim(`Port: ${CLIPROXY_DEFAULT_PORT} (default)`)}`); - console.log(''); + writeLine(subheader('CLI Proxy:')); + writeLine(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`); + writeLine(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`); + writeLine(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`); + writeLine(` ${dim(`Port: ${CLIPROXY_DEFAULT_PORT} (default)`)}`); + writeLine(''); // Shared Data - console.log(subheader('Shared Data:')); - console.log(` Commands: ${color(`${dirDisplay}/shared/commands/`, 'path')}`); - console.log(` Skills: ${color(`${dirDisplay}/shared/skills/`, 'path')}`); - console.log(` Agents: ${color(`${dirDisplay}/shared/agents/`, 'path')}`); - console.log(` ${dim('Note: Symlinked across all profiles')}`); - console.log(''); + writeLine(subheader('Shared Data:')); + writeLine(` Commands: ${color(`${dirDisplay}/shared/commands/`, 'path')}`); + writeLine(` Skills: ${color(`${dirDisplay}/shared/skills/`, 'path')}`); + writeLine(` Agents: ${color(`${dirDisplay}/shared/agents/`, 'path')}`); + writeLine(` ${dim('Note: Symlinked across all profiles')}`); + writeLine(''); // Examples (aligned with consistent spacing) - console.log(subheader('Examples:')); - console.log(` $ ${color('ccs', 'command')} ${dim('# Use default account')}`); - console.log( + writeLine(subheader('Examples:')); + writeLine(` $ ${color('ccs', 'command')} ${dim('# Use default account')}`); + writeLine( ` $ ${color('ccs gemini', 'command')} ${dim('# OAuth (browser opens first time)')}` ); - console.log(` $ ${color('ccs glm "implement API"', 'command')} ${dim('# API key model')}`); - console.log(` $ ${color('ccs config', 'command')} ${dim('# Open web dashboard')}`); - console.log(''); + writeLine(` $ ${color('ccs glm "implement API"', 'command')} ${dim('# API key model')}`); + writeLine(` $ ${color('ccs config', 'command')} ${dim('# Open web dashboard')}`); + writeLine(''); // Update examples - console.log(subheader('Update:')); - console.log( + writeLine(subheader('Update:')); + writeLine( ` $ ${color('ccs update', 'command')} ${dim('# Update to latest stable')}` ); - console.log( + writeLine( ` $ ${color('ccs update --force', 'command')} ${dim('# Force reinstall current')}` ); - console.log(` $ ${color('ccs update --beta', 'command')} ${dim('# Install dev channel')}`); - console.log(''); + writeLine(` $ ${color('ccs update --beta', 'command')} ${dim('# Install dev channel')}`); + writeLine(''); // Docs link - console.log(` ${dim('Docs: https://github.com/kaitranntt/ccs')}`); - console.log(''); + writeLine(` ${dim('Docs: https://github.com/kaitranntt/ccs')}`); + writeLine(''); // Uninstall - console.log(subheader('Uninstall:')); - console.log(` ${color('npm uninstall -g @kaitranntt/ccs', 'command')}`); - console.log(''); + writeLine(subheader('Uninstall:')); + writeLine(` ${color('npm uninstall -g @kaitranntt/ccs', 'command')}`); + writeLine(''); // License - console.log(dim('License: MIT')); - console.log(''); + writeLine(dim('License: MIT')); + writeLine(''); } diff --git a/tests/unit/commands/api-command-router.test.ts b/tests/unit/commands/api-command-router.test.ts index 25d33812..450a7326 100644 --- a/tests/unit/commands/api-command-router.test.ts +++ b/tests/unit/commands/api-command-router.test.ts @@ -1,74 +1,48 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { beforeEach, describe, expect, it } from 'bun:test'; + +import { createApiCommandHandler } from '../../../src/commands/api-command'; let calls: string[] = []; beforeEach(() => { calls = []; +}); - mock.module('../../../src/commands/api-command/help', () => ({ - showApiCommandHelp: async () => { +function buildHandleApiCommand() { + return createApiCommandHandler({ + help: async () => { calls.push('help'); }, - showUnknownApiCommand: async (command: string) => { + unknown: async (command: string) => { calls.push(`unknown:${command}`); }, - })); - - mock.module('../../../src/commands/api-command/create-command', () => ({ - handleApiCreateCommand: async (args: string[]) => { + create: async (args: string[]) => { calls.push(`create:${args.join(' ')}`); }, - })); - - mock.module('../../../src/commands/api-command/list-command', () => ({ - handleApiListCommand: async (args: string[]) => { + list: async (args: string[]) => { calls.push(`list:${args.join(' ')}`); }, - })); - - mock.module('../../../src/commands/api-command/remove-command', () => ({ - handleApiRemoveCommand: async (args: string[]) => { - calls.push(`remove:${args.join(' ')}`); - }, - })); - - mock.module('../../../src/commands/api-command/discover-command', () => ({ - handleApiDiscoverCommand: async (args: string[]) => { + discover: async (args: string[]) => { calls.push(`discover:${args.join(' ')}`); }, - })); - - mock.module('../../../src/commands/api-command/copy-command', () => ({ - handleApiCopyCommand: async (args: string[]) => { + copy: async (args: string[]) => { calls.push(`copy:${args.join(' ')}`); }, - })); - - mock.module('../../../src/commands/api-command/export-command', () => ({ - handleApiExportCommand: async (args: string[]) => { + export: async (args: string[]) => { calls.push(`export:${args.join(' ')}`); }, - })); - - mock.module('../../../src/commands/api-command/import-command', () => ({ - handleApiImportCommand: async (args: string[]) => { + import: async (args: string[]) => { calls.push(`import:${args.join(' ')}`); }, - })); -}); - -afterEach(() => { - mock.restore(); -}); - -async function loadHandleApiCommand() { - const mod = await import(`../../../src/commands/api-command?test=${Date.now()}-${Math.random()}`); - return mod.handleApiCommand; + remove: async (args: string[]) => { + calls.push(`remove:${args.join(' ')}`); + }, + }); } describe('api-command router', () => { it('defaults to help when no subcommand is provided', async () => { - const handleApiCommand = await loadHandleApiCommand(); + const handleApiCommand = buildHandleApiCommand(); await handleApiCommand([]); @@ -76,7 +50,7 @@ describe('api-command router', () => { }); it('routes remove aliases through the named command dispatcher', async () => { - const handleApiCommand = await loadHandleApiCommand(); + const handleApiCommand = buildHandleApiCommand(); await handleApiCommand(['rm', 'profile-a']); @@ -84,7 +58,7 @@ describe('api-command router', () => { }); it('forwards list arguments to the handler for validation', async () => { - const handleApiCommand = await loadHandleApiCommand(); + const handleApiCommand = buildHandleApiCommand(); await handleApiCommand(['list', 'unexpected']); @@ -92,7 +66,7 @@ describe('api-command router', () => { }); it('routes hardened subcommands through their handlers', async () => { - const handleApiCommand = await loadHandleApiCommand(); + const handleApiCommand = buildHandleApiCommand(); await handleApiCommand(['discover', '--json']); await handleApiCommand(['copy', 'source', 'dest']); @@ -108,7 +82,7 @@ describe('api-command router', () => { }); it('delegates unknown commands to the shared unknown handler', async () => { - const handleApiCommand = await loadHandleApiCommand(); + const handleApiCommand = buildHandleApiCommand(); await handleApiCommand(['bogus']); diff --git a/tests/unit/commands/config-auth-command.test.ts b/tests/unit/commands/config-auth-command.test.ts index 3f3182a7..5674b652 100644 --- a/tests/unit/commands/config-auth-command.test.ts +++ b/tests/unit/commands/config-auth-command.test.ts @@ -15,20 +15,6 @@ beforeEach(() => { logLines.push(args.map(String).join(' ')); }; - const uiModule = { - initUI: async () => {}, - header: (message: string) => message, - subheader: (message: string) => message, - color: (message: string) => message, - dim: (message: string) => message, - ok: (message: string) => message, - info: (message: string) => message, - warn: (message: string) => message, - fail: (message: string) => message, - }; - mock.module('../../../src/utils/ui', () => uiModule); - mock.module('../../../src/utils/ui.ts', () => uiModule); - mock.module('../../../src/commands/config-auth/setup-command', () => ({ handleSetup: async () => { calls.push('setup'); @@ -55,9 +41,7 @@ afterEach(() => { }); async function loadHandleConfigAuthCommand() { - const mod = await import( - `../../../src/commands/config-auth?test=${Date.now()}-${Math.random()}` - ); + const mod = await import(`../../../src/commands/config-auth?test=${Date.now()}-${Math.random()}`); return mod.handleConfigAuthCommand; } diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 451ea81a..512bb5f8 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import { showApiCommandHelp } from '../../../src/commands/api-command/help'; import { handleHelpCommand } from '../../../src/commands/help-command'; @@ -8,19 +8,9 @@ function stripAnsi(input: string): string { } describe('help command parity', () => { - const originalLog = console.log; - - afterEach(() => { - console.log = originalLog; - }); - test('root help documents cliproxy provider filter under quota command', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('ccs cliproxy status [provider]')).toBe(false); @@ -31,11 +21,7 @@ describe('help command parity', () => { test('root help documents llama.cpp as a local API profile', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('ccs llamacpp')).toBe(true); @@ -44,11 +30,7 @@ describe('help command parity', () => { test('root help no longer markets glmt as a supported profile', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('ccs glmt')).toBe(false); @@ -57,11 +39,7 @@ describe('help command parity', () => { test('root help documents Claude IDE extension setup surfaces', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('Claude IDE Extension setup page')).toBe(true); @@ -75,11 +53,7 @@ describe('help command parity', () => { test('root help documents dashboard host binding example', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('ccs config --host 0.0.0.0')).toBe(true); @@ -88,48 +62,36 @@ describe('help command parity', () => { test('root help documents official channels native-only scope and process-env tokens', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('Dashboard -> Settings -> Channels (fastest path)')).toBe(true); expect( - rendered.includes('Fastest path: turn on the channel, save the token if needed, then run ccs.') + rendered.includes( + 'Fastest path: turn on the channel, save the token if needed, then run ccs.' + ) ).toBe(true); expect(rendered.includes('Not supported for ccs glm')).toBe(true); - expect(rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work')).toBe( - true - ); + expect( + rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work') + ).toBe(true); }); test('root help explains Claude [1m] as an explicit CCS suffix with upstream limits', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('Claude models: plain by default, opt-in with --1m or saved [1m]')).toBe( - true - ); + expect( + rendered.includes('Claude models: plain by default, opt-in with --1m or saved [1m]') + ).toBe(true); expect(rendered.includes('CCS only controls the saved [1m] suffix.')).toBe(true); - expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe( - true - ); + expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe(true); }); test('api help documents create-time Claude [1m] flags and entitlement warning', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await showApiCommandHelp(); + await showApiCommandHelp((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('--1m / --no-1m')).toBe(true);