From 8d2ec861551a68d30442a2e548e08578d511cb49 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 01:31:16 +0700 Subject: [PATCH] refactor(core): centralize claude paths and command parsing --- src/ccs.ts | 184 +++++++----------- .../config/extended-context-config.ts | 36 ++-- src/commands/api-command.ts | 41 ++-- src/commands/arg-extractor.ts | 66 +++++++ src/commands/cliproxy/index.ts | 178 +++++------------ src/commands/config-command.ts | 34 ++-- src/commands/config-image-analysis-command.ts | 49 ++--- src/commands/persist-command.ts | 54 ++--- src/management/checks/config-check.ts | 4 +- src/shared/extended-context-utils.ts | 29 +++ src/utils/claude-config-path.ts | 26 +++ src/utils/websearch/hook-config.ts | 17 +- src/web-server/jsonl-parser.ts | 5 +- src/web-server/routes/persist-routes.ts | 7 +- src/web-server/routes/route-helpers.ts | 3 +- src/web-server/shared-routes.ts | 4 +- ui/src/lib/extended-context-utils.ts | 51 +---- ui/src/lib/provider-config.ts | 93 +++------ 18 files changed, 395 insertions(+), 486 deletions(-) create mode 100644 src/commands/arg-extractor.ts create mode 100644 src/shared/extended-context-utils.ts create mode 100644 src/utils/claude-config-path.ts diff --git a/src/ccs.ts b/src/ccs.ts index b1d89e27..5676fd96 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -433,56 +433,6 @@ async function main(): Promise { console.warn('[!] Recovery failed:', (err as Error).message); } - // Special case: version command (check BEFORE profile detection) - if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') { - await handleVersionCommand(); - return; - } - - // Special case: help command - if (firstArg === '--help' || firstArg === '-h' || firstArg === 'help') { - await handleHelpCommand(); - return; - } - - // Special case: install command - if (firstArg === '--install') { - handleInstallCommand(); - return; - } - - // Special case: uninstall command - if (firstArg === '--uninstall') { - handleUninstallCommand(); - return; - } - - // Special case: shell completion installer - if (firstArg === '--shell-completion' || firstArg === '-sc') { - await handleShellCompletionCommand(args.slice(1)); - return; - } - - // Special case: doctor command - if (firstArg === 'doctor' || firstArg === '--doctor') { - const restArgs = args.slice(args.indexOf(firstArg) + 1); - await handleDoctorCommand(restArgs); - return; - } - - // Special case: sync command - if (firstArg === 'sync' || firstArg === '--sync') { - await handleSyncCommand(); - return; - } - - // Special case: cleanup command - if (firstArg === 'cleanup' || firstArg === '--cleanup') { - const { handleCleanupCommand } = await import('./commands/cleanup-command'); - await handleCleanupCommand(args.slice(1)); - return; - } - // Special case: migrate command if (firstArg === 'migrate' || firstArg === '--migrate') { const { handleMigrateCommand, printMigrateHelp } = await import('./commands/migrate-command'); @@ -525,72 +475,80 @@ async function main(): Promise { return; } - // Special case: auth command - if (firstArg === 'auth') { - const AuthCommandsModule = await import('./auth/auth-commands'); - const AuthCommands = AuthCommandsModule.default; - const authCommands = new AuthCommands(); - await authCommands.route(args.slice(1)); + const commandAliases: Record = { + '--version': 'version', + '-v': 'version', + '--help': 'help', + '-h': 'help', + '--doctor': 'doctor', + '--sync': 'sync', + '--cleanup': 'cleanup', + '--setup': 'setup', + }; + + const normalizedFirstArg = commandAliases[firstArg] || firstArg; + + const earlyCommandHandlers: Record Promise> = { + version: async () => handleVersionCommand(), + help: async () => handleHelpCommand(), + '--install': async () => handleInstallCommand(), + '--uninstall': async () => handleUninstallCommand(), + '--shell-completion': async () => handleShellCompletionCommand(args.slice(1)), + '-sc': async () => handleShellCompletionCommand(args.slice(1)), + doctor: async () => handleDoctorCommand(args.slice(1)), + sync: async () => handleSyncCommand(), + cleanup: async () => { + const { handleCleanupCommand } = await import('./commands/cleanup-command'); + await handleCleanupCommand(args.slice(1)); + }, + auth: async () => { + const AuthCommandsModule = await import('./auth/auth-commands'); + const AuthCommands = AuthCommandsModule.default; + const authCommands = new AuthCommands(); + await authCommands.route(args.slice(1)); + }, + api: async () => { + const { handleApiCommand } = await import('./commands/api-command'); + await handleApiCommand(args.slice(1)); + }, + cliproxy: async () => { + const { handleCliproxyCommand } = await import('./commands/cliproxy-command'); + await handleCliproxyCommand(args.slice(1)); + }, + config: async () => { + const { handleConfigCommand } = await import('./commands/config-command'); + await handleConfigCommand(args.slice(1)); + }, + tokens: async () => { + const { handleTokensCommand } = await import('./commands/tokens-command'); + const exitCode = await handleTokensCommand(args.slice(1)); + process.exit(exitCode); + }, + persist: async () => { + const { handlePersistCommand } = await import('./commands/persist-command'); + await handlePersistCommand(args.slice(1)); + }, + env: async () => { + const { handleEnvCommand } = await import('./commands/env-command'); + await handleEnvCommand(args.slice(1)); + }, + setup: async () => { + const { handleSetupCommand } = await import('./commands/setup-command'); + await handleSetupCommand(args.slice(1)); + }, + cursor: async () => { + const { handleCursorCommand } = await import('./commands/cursor-command'); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + }, + }; + + const earlyCommandHandler = earlyCommandHandlers[normalizedFirstArg]; + if (earlyCommandHandler) { + await earlyCommandHandler(); return; } - // Special case: api command (manages API profiles) - if (firstArg === 'api') { - const { handleApiCommand } = await import('./commands/api-command'); - await handleApiCommand(args.slice(1)); - return; - } - - // Special case: cliproxy command (manages CLIProxyAPI binary) - if (firstArg === 'cliproxy') { - const { handleCliproxyCommand } = await import('./commands/cliproxy-command'); - await handleCliproxyCommand(args.slice(1)); - return; - } - - // Special case: config command (web dashboard) - if (firstArg === 'config') { - const { handleConfigCommand } = await import('./commands/config-command'); - await handleConfigCommand(args.slice(1)); - return; - } - - // Special case: tokens command (auth token management) - if (firstArg === 'tokens') { - const { handleTokensCommand } = await import('./commands/tokens-command'); - const exitCode = await handleTokensCommand(args.slice(1)); - process.exit(exitCode); - } - - // Special case: persist command (write profile env to ~/.claude/settings.json) - if (firstArg === 'persist') { - const { handlePersistCommand } = await import('./commands/persist-command'); - await handlePersistCommand(args.slice(1)); - return; - } - - // Special case: env command (export env vars for third-party tools) - if (firstArg === 'env') { - const { handleEnvCommand } = await import('./commands/env-command'); - await handleEnvCommand(args.slice(1)); - return; - } - - // Special case: setup command (first-time wizard) - if (firstArg === 'setup' || firstArg === '--setup') { - const { handleSetupCommand } = await import('./commands/setup-command'); - await handleSetupCommand(args.slice(1)); - return; - } - - // Special case: cursor command (Cursor IDE integration) - // All `ccs cursor *` routes to cursor command handler — cursor has no profile-switching mode - if (firstArg === 'cursor') { - const { handleCursorCommand } = await import('./commands/cursor-command'); - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } - // Special case: copilot command (GitHub Copilot integration) // Only route to command handler for known subcommands, otherwise treat as profile const COPILOT_SUBCOMMANDS = [ diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 6090bf84..60616e09 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -10,26 +10,17 @@ */ import { CLIProxyProvider } from '../types'; -import { supportsExtendedContext, isNativeGeminiModel } from '../model-catalog'; +import { supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; +import { + applyExtendedContextSuffix as applyExtendedContextSuffixShared, + isNativeGeminiModel, + stripExtendedContextSuffix, +} from '../../shared/extended-context-utils'; -/** Extended context suffix recognized by Claude Code */ -const EXTENDED_CONTEXT_SUFFIX = '[1m]'; - -/** - * Apply extended context suffix to model name. - * Appends [1m] suffix if not already present. - * - * @param model - Model name (may include thinking suffix like "model(high)") - * @returns Model name with [1m] suffix, e.g., "gemini-2.5-pro[1m]" or "gemini-2.5-pro(high)[1m]" - */ -export function applyExtendedContextSuffix(model: string): string { - if (!model) return model; - // Case-insensitive check to avoid double suffix (handles [1M], [1m], etc.) - if (model.toLowerCase().endsWith(EXTENDED_CONTEXT_SUFFIX.toLowerCase())) { - return model; - } - return `${model}${EXTENDED_CONTEXT_SUFFIX}`; +// Backward-compatible export retained for tests/importers that reference this module. +export function applyExtendedContextSuffix(modelId: string): string { + return applyExtendedContextSuffixShared(modelId); } /** @@ -109,7 +100,7 @@ export function applyExtendedContextConfig( // Apply suffix to main model if (envVars.ANTHROPIC_MODEL) { - envVars.ANTHROPIC_MODEL = applyExtendedContextSuffix(envVars.ANTHROPIC_MODEL); + envVars.ANTHROPIC_MODEL = applyExtendedContextSuffixShared(envVars.ANTHROPIC_MODEL); } // Apply to tier models if they support extended context @@ -119,7 +110,7 @@ export function applyExtendedContextConfig( if (model) { const tierCleanId = stripModelSuffixes(model); if (shouldApplyExtendedContext(provider, tierCleanId, extendedContextOverride)) { - envVars[tierVar] = applyExtendedContextSuffix(model); + envVars[tierVar] = applyExtendedContextSuffixShared(model); } } } @@ -133,8 +124,5 @@ export function applyExtendedContextConfig( * "gemini-2.5-pro" -> "gemini-2.5-pro" */ function stripModelSuffixes(modelId: string): string { - return modelId - .trim() - .replace(/\[1m\]$/i, '') // Remove [1m] suffix - .replace(/\([^)]+\)$/, ''); // Remove thinking suffix like (high) or (8192) + return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); } diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index cdd8c817..fd15cdbd 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -43,6 +43,7 @@ import { type ProviderPreset, } from '../api/services'; import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync'; +import { extractOption, hasAnyFlag } from './arg-extractor'; interface ApiCommandArgs { name?: string; @@ -72,28 +73,30 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { /** Parse command line arguments for api commands */ function parseArgs(args: string[]): ApiCommandArgs { - const result: ApiCommandArgs = {}; + const result: ApiCommandArgs = { + force: hasAnyFlag(args, ['--force']), + yes: hasAnyFlag(args, ['--yes', '-y']), + }; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; + let remaining = [...args]; - if (arg === '--base-url' && args[i + 1]) { - result.baseUrl = args[++i]; - } else if (arg === '--api-key' && args[i + 1]) { - result.apiKey = args[++i]; - } else if (arg === '--model' && args[i + 1]) { - result.model = args[++i]; - } else if (arg === '--preset' && args[i + 1]) { - result.preset = args[++i]; - } else if (arg === '--force') { - result.force = true; - } else if (arg === '--yes' || arg === '-y') { - result.yes = true; - } else if (!arg.startsWith('-') && !result.name) { - result.name = arg; - } - } + const baseUrl = extractOption(remaining, ['--base-url']); + if (baseUrl.value) result.baseUrl = baseUrl.value; + remaining = baseUrl.remainingArgs; + const apiKey = extractOption(remaining, ['--api-key']); + if (apiKey.value) result.apiKey = apiKey.value; + remaining = apiKey.remainingArgs; + + const model = extractOption(remaining, ['--model']); + if (model.value) result.model = model.value; + remaining = model.remainingArgs; + + const preset = extractOption(remaining, ['--preset']); + if (preset.value) result.preset = preset.value; + remaining = preset.remainingArgs; + + result.name = remaining.find((arg) => !arg.startsWith('-')); return result; } diff --git a/src/commands/arg-extractor.ts b/src/commands/arg-extractor.ts new file mode 100644 index 00000000..a4a45dcd --- /dev/null +++ b/src/commands/arg-extractor.ts @@ -0,0 +1,66 @@ +/** + * Small helpers for consistent CLI option extraction. + */ + +export interface ExtractedOption { + found: boolean; + value?: string; + missingValue: boolean; + remainingArgs: string[]; +} + +function findInlineOption(arg: string, flag: string): string | undefined { + const prefix = `${flag}=`; + return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined; +} + +/** + * Extract a single-value option and remove it from args. + * Supports `--flag value` and `--flag=value` forms. + */ +export function extractOption(args: string[], flags: readonly string[]): ExtractedOption { + const remaining = [...args]; + + for (let i = 0; i < remaining.length; i++) { + const token = remaining[i]; + + for (const flag of flags) { + if (token === flag) { + const next = remaining[i + 1]; + if (!next || next.startsWith('-')) { + remaining.splice(i, 1); + return { found: true, missingValue: true, remainingArgs: remaining }; + } + + remaining.splice(i, 2); + return { + found: true, + value: next, + missingValue: false, + remainingArgs: remaining, + }; + } + + const inlineValue = findInlineOption(token, flag); + if (inlineValue !== undefined) { + remaining.splice(i, 1); + if (!inlineValue.trim()) { + return { found: true, missingValue: true, remainingArgs: remaining }; + } + return { + found: true, + value: inlineValue, + missingValue: false, + remainingArgs: remaining, + }; + } + } + } + + return { found: false, missingValue: false, remainingArgs: remaining }; +} + +/** Returns true if any of the provided boolean flags are present. */ +export function hasAnyFlag(args: string[], flags: readonly string[]): boolean { + return args.some((arg) => flags.includes(arg)); +} diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 60a24aec..f592dc3f 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -9,6 +9,7 @@ import { CLIProxyBackend } from '../../cliproxy/types'; import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { handleSync } from '../cliproxy-sync-handler'; +import { extractOption, hasAnyFlag } from '../arg-extractor'; // Import subcommand handlers import { handleList } from './auth-subcommand'; @@ -42,30 +43,23 @@ function parseBackendArg(args: string[]): { backend: CLIProxyBackend | undefined; remainingArgs: string[]; } { - const backendIdx = args.indexOf('--backend'); - if (backendIdx === -1) { - // Also check for --backend=value format - const backendEqualsIdx = args.findIndex((a) => a.startsWith('--backend=')); - if (backendEqualsIdx !== -1) { - const value = args[backendEqualsIdx].split('=')[1] as CLIProxyBackend; - if (value !== 'original' && value !== 'plus') { - console.warn(`Invalid backend '${value}'. Valid options: original, plus`); - return { backend: undefined, remainingArgs: args }; - } - const remainingArgs = [...args]; - remainingArgs.splice(backendEqualsIdx, 1); - return { backend: value, remainingArgs }; - } + const extracted = extractOption(args, ['--backend']); + if (!extracted.found) { return { backend: undefined, remainingArgs: args }; } - const value = args[backendIdx + 1]; + + if (extracted.missingValue || !extracted.value) { + console.warn(`Invalid backend ''. Valid options: original, plus`); + return { backend: undefined, remainingArgs: extracted.remainingArgs }; + } + + const value = extracted.value as CLIProxyBackend; if (value !== 'original' && value !== 'plus') { console.warn(`Invalid backend '${value}'. Valid options: original, plus`); - return { backend: undefined, remainingArgs: args }; + return { backend: undefined, remainingArgs: extracted.remainingArgs }; } - const remainingArgs = [...args]; - remainingArgs.splice(backendIdx, 2); - return { backend: value, remainingArgs }; + + return { backend: value, remainingArgs: extracted.remainingArgs }; } /** @@ -86,54 +80,19 @@ function parseProviderArg(args: string[]): { provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all'; remainingArgs: string[]; } { - const providerIdx = args.indexOf('--provider'); - if (providerIdx === -1) { - // Also check for --provider=value format - const providerEqualsIdx = args.findIndex((a) => a.startsWith('--provider=')); - if (providerEqualsIdx !== -1) { - const value = args[providerEqualsIdx].split('=')[1]?.toLowerCase() || ''; - const remainingArgs = [...args]; - remainingArgs.splice(providerEqualsIdx, 1); - // Handle empty value - if (!value) { - console.error( - 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' - ); - return { provider: 'all', remainingArgs }; - } - // Normalize gemini-cli to gemini - const normalized = - value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; - if ( - normalized !== 'agy' && - normalized !== 'codex' && - normalized !== 'gemini' && - normalized !== 'ghcp' && - normalized !== 'all' - ) { - console.error( - `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` - ); - return { provider: 'all', remainingArgs }; - } - return { - provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', - remainingArgs, - }; - } + const extracted = extractOption(args, ['--provider']); + if (!extracted.found) { return { provider: 'all', remainingArgs: args }; } - const rawValue = args[providerIdx + 1]; - // Warn if no value or value looks like another flag - if (!rawValue || rawValue.startsWith('-')) { + + if (extracted.missingValue || !extracted.value) { console.error( 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' ); + return { provider: 'all', remainingArgs: extracted.remainingArgs }; } - const value = rawValue?.toLowerCase() || 'all'; - const remainingArgs = [...args]; - remainingArgs.splice(providerIdx, 2); - // Normalize gemini-cli to gemini + + const value = extracted.value.toLowerCase(); const normalized = value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; if ( @@ -146,11 +105,11 @@ function parseProviderArg(args: string[]): { console.error( `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` ); - return { provider: 'all', remainingArgs }; + return { provider: 'all', remainingArgs: extracted.remainingArgs }; } return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', - remainingArgs, + remainingArgs: extracted.remainingArgs, }; } @@ -162,35 +121,14 @@ export async function handleCliproxyCommand(args: string[]): Promise { const { backend: cliBackend, remainingArgs } = parseBackendArg(args); const effectiveBackend = getEffectiveBackend(cliBackend); - const verbose = remainingArgs.includes('--verbose') || remainingArgs.includes('-v'); + const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']); const command = remainingArgs[0]; - if (remainingArgs.includes('--help') || remainingArgs.includes('-h')) { + if (hasAnyFlag(remainingArgs, ['--help', '-h'])) { await showHelp(); return; } - // Profile commands - if (command === 'create') { - await handleCreate(remainingArgs.slice(1), effectiveBackend); - return; - } - - if (command === 'edit') { - await handleEdit(remainingArgs.slice(1), effectiveBackend); - return; - } - - if (command === 'list' || command === 'ls') { - await handleList(); - return; - } - - if (command === 'remove' || command === 'delete' || command === 'rm') { - await handleRemove(remainingArgs.slice(1)); - return; - } - // Catalog commands if (command === 'catalog') { const subcommand = remainingArgs[1]; @@ -212,55 +150,37 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } - // Proxy lifecycle commands - if (command === 'start') { - await handleStart(verbose); - return; - } - - if (command === 'stop') { - await handleStop(); - return; - } - - if (command === 'restart') { - await handleRestart(verbose); - return; - } - - if (command === 'status') { - await handleProxyStatus(); - return; - } - - // Diagnostics - if (command === 'doctor' || command === 'diag') { - await handleDoctor(verbose); - return; - } - - // Quota management commands - if (command === 'default') { - await handleSetDefault(remainingArgs.slice(1)); - return; - } - - if (command === 'pause') { - await handlePauseAccount(remainingArgs.slice(1)); - return; - } - - if (command === 'resume') { - await handleResumeAccount(remainingArgs.slice(1)); - return; - } - if (command === 'quota') { const { provider: providerFilter } = parseProviderArg(remainingArgs.slice(1)); await handleQuotaStatus(verbose, providerFilter); return; } + const commandHandlers: Record Promise> = { + create: async () => handleCreate(remainingArgs.slice(1), effectiveBackend), + edit: async () => handleEdit(remainingArgs.slice(1), effectiveBackend), + list: async () => handleList(), + ls: async () => handleList(), + remove: async () => handleRemove(remainingArgs.slice(1)), + delete: async () => handleRemove(remainingArgs.slice(1)), + rm: async () => handleRemove(remainingArgs.slice(1)), + start: async () => handleStart(verbose), + stop: async () => handleStop(), + restart: async () => handleRestart(verbose), + status: async () => handleProxyStatus(), + doctor: async () => handleDoctor(verbose), + diag: async () => handleDoctor(verbose), + default: async () => handleSetDefault(remainingArgs.slice(1)), + pause: async () => handlePauseAccount(remainingArgs.slice(1)), + resume: async () => handleResumeAccount(remainingArgs.slice(1)), + }; + + const commandHandler = command ? commandHandlers[command] : undefined; + if (commandHandler) { + await commandHandler(); + return; + } + // Binary installation commands const installIdx = remainingArgs.indexOf('--install'); if (installIdx !== -1) { diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 906fac3d..28bb0dfa 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -13,6 +13,7 @@ import { setupGracefulShutdown } from '../web-server/shutdown'; import { ensureCliproxyService } from '../cliproxy/service-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; import { initUI, header, ok, info, warn, fail } from '../utils/ui'; +import { extractOption, hasAnyFlag } from './arg-extractor'; interface ConfigOptions { port?: number; @@ -25,25 +26,28 @@ interface ConfigOptions { function parseArgs(args: string[]): ConfigOptions { const result: ConfigOptions = {}; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; + if (hasAnyFlag(args, ['--help', '-h'])) { + showHelp(); + process.exit(0); + } - if ((arg === '--port' || arg === '-p') && args[i + 1]) { - const port = parseInt(args[++i], 10); - if (!isNaN(port) && port > 0 && port < 65536) { - result.port = port; - } else { - console.error(fail('Invalid port number')); - process.exit(1); - } - } else if (arg === '--dev') { - result.dev = true; - } else if (arg === '--help' || arg === '-h') { - showHelp(); - process.exit(0); + const portOption = extractOption(args, ['--port', '-p']); + if (portOption.found) { + if (portOption.missingValue || !portOption.value) { + console.error(fail('Invalid port number')); + process.exit(1); + } + + const port = parseInt(portOption.value, 10); + if (!isNaN(port) && port > 0 && port < 65536) { + result.port = port; + } else { + console.error(fail('Invalid port number')); + process.exit(1); } } + result.dev = hasAnyFlag(args, ['--dev']); return result; } diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index bc13f8c1..93c44602 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -12,6 +12,8 @@ import { loadOrCreateUnifiedConfig, } from '../config/unified-config-loader'; import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types'; +import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; +import { extractOption, hasAnyFlag } from './arg-extractor'; interface ImageAnalysisCommandOptions { enable?: boolean; @@ -22,29 +24,28 @@ interface ImageAnalysisCommandOptions { } function parseArgs(args: string[]): ImageAnalysisCommandOptions { - const options: ImageAnalysisCommandOptions = {}; + const options: ImageAnalysisCommandOptions = { + enable: hasAnyFlag(args, ['--enable']), + disable: hasAnyFlag(args, ['--disable']), + help: hasAnyFlag(args, ['--help', '-h']), + }; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; + const timeoutOption = extractOption(args, ['--timeout']); + if (timeoutOption.found) { + const timeout = parseInt(timeoutOption.value || '', 10); + if (isNaN(timeout) || timeout < 10 || timeout > 600) { + console.error(fail('Timeout must be between 10 and 600 seconds')); + process.exit(1); + } + options.timeout = timeout; + } - if (arg === '--enable') { - options.enable = true; - } else if (arg === '--disable') { - options.disable = true; - } else if (arg === '--timeout' && args[i + 1]) { - const timeout = parseInt(args[++i], 10); - if (isNaN(timeout) || timeout < 10 || timeout > 600) { - console.error(fail('Timeout must be between 10 and 600 seconds')); - process.exit(1); - } - options.timeout = timeout; - } else if (arg === '--set-model' && args[i + 1] && args[i + 2]) { - options.setModel = { - provider: args[++i], - model: args[++i], - }; - } else if (arg === '--help' || arg === '-h') { - options.help = true; + const setModelIdx = args.indexOf('--set-model'); + if (setModelIdx !== -1) { + const provider = args[setModelIdx + 1]; + const model = args[setModelIdx + 2]; + if (provider && model && !provider.startsWith('-') && !model.startsWith('-')) { + options.setModel = { provider, model }; } } @@ -186,8 +187,10 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< } if (options.setModel) { - const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; - if (!validProviders.includes(options.setModel.provider)) { + const validProviders = [...CLIPROXY_PROVIDER_IDS]; + if ( + !validProviders.includes(options.setModel.provider as (typeof CLIPROXY_PROVIDER_IDS)[number]) + ) { console.error(fail(`Invalid provider: ${options.setModel.provider}`)); console.error(info(`Valid providers: ${validProviders.join(', ')}`)); process.exit(1); diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index 514afa8f..eba20cc5 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -10,7 +10,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; import ProfileDetector, { @@ -21,6 +20,8 @@ import ProfileDetector, { import { getEffectiveEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; import { generateCopilotEnv } from '../copilot/copilot-executor'; import { expandPath } from '../utils/helpers'; +import { getClaudeConfigDir, getClaudeSettingsPath } from '../utils/claude-config-path'; +import { extractOption, hasAnyFlag } from './arg-extractor'; interface PersistCommandArgs { profile?: string; @@ -37,34 +38,37 @@ interface ResolvedEnv { /** Parse command line arguments */ function parseArgs(args: string[]): PersistCommandArgs { - const result: PersistCommandArgs = {}; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--yes' || arg === '-y') { - result.yes = true; - } else if (arg === '--help' || arg === '-h') { - // Will be handled in main function - } else if (arg === '--list-backups') { - result.listBackups = true; - } else if (arg === '--restore') { - // Check if next arg is a timestamp (not a flag) - const nextArg = args[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - result.restore = nextArg; - i++; // Skip next arg - } else { - result.restore = true; // Use latest - } - } else if (!arg.startsWith('-') && !result.profile) { + const result: PersistCommandArgs = { + yes: hasAnyFlag(args, ['--yes', '-y']), + listBackups: hasAnyFlag(args, ['--list-backups']), + }; + + const restoreOption = extractOption(args, ['--restore']); + if (restoreOption.found) { + result.restore = restoreOption.missingValue ? true : restoreOption.value || true; + } + + for (const arg of restoreOption.remainingArgs) { + if (!arg.startsWith('-')) { result.profile = arg; + break; } } return result; } -/** Get Claude settings.json path */ -function getClaudeSettingsPath(): string { - return path.join(os.homedir(), '.claude', 'settings.json'); +function formatDisplayPath(filePath: string): string { + const claudeDir = getClaudeConfigDir(); + if (filePath === claudeDir) { + return '~/.claude'; + } + + const claudePrefix = `${claudeDir}${path.sep}`; + if (filePath.startsWith(claudePrefix)) { + return filePath.replace(claudePrefix, '~/.claude/'); + } + + return filePath; } /** Read existing Claude settings.json with validation */ @@ -517,7 +521,7 @@ export async function handlePersistCommand(args: string[]): Promise { if (createBackupFlag) { try { createdBackupPath = createBackup(); - console.log(ok(`Backup created: ${createdBackupPath.replace(os.homedir(), '~')}`)); + console.log(ok(`Backup created: ${formatDisplayPath(createdBackupPath)}`)); console.log(''); } catch (error) { console.log(fail(`Failed to create backup: ${(error as Error).message}`)); @@ -560,7 +564,7 @@ export async function handlePersistCommand(args: string[]): Promise { if (createdBackupPath) { console.log(''); console.log(info(`A backup was created before this error:`)); - console.log(` ${createdBackupPath.replace(os.homedir(), '~')}`); + console.log(` ${formatDisplayPath(createdBackupPath)}`); console.log(dim(' To restore: ccs persist --restore')); } process.exit(1); diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts index c05d8457..8c1af017 100644 --- a/src/management/checks/config-check.ts +++ b/src/management/checks/config-check.ts @@ -4,10 +4,10 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { ok, fail, warn, info } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; import { getCcsDir } from '../../utils/config-manager'; +import { getClaudeConfigDir } from '../../utils/claude-config-path'; const ora = createSpinner(); @@ -195,7 +195,7 @@ export class ClaudeSettingsChecker implements IHealthChecker { private readonly claudeDir: string; constructor() { - this.claudeDir = path.join(os.homedir(), '.claude'); + this.claudeDir = getClaudeConfigDir(); } run(results: HealthCheck): void { diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts new file mode 100644 index 00000000..88b0486a --- /dev/null +++ b/src/shared/extended-context-utils.ts @@ -0,0 +1,29 @@ +/** + * Shared extended context helpers used by CLI + UI. + */ + +/** Extended context suffix recognized by Claude Code. */ +export const EXTENDED_CONTEXT_SUFFIX = '[1m]'; + +/** Check if model is a native Gemini model (auto-enabled behavior). */ +export function isNativeGeminiModel(modelId: string): boolean { + return modelId.toLowerCase().startsWith('gemini-'); +} + +/** Check if model already has [1m] suffix. */ +export function hasExtendedContextSuffix(model: string): boolean { + return model.toLowerCase().endsWith(EXTENDED_CONTEXT_SUFFIX.toLowerCase()); +} + +/** Apply [1m] suffix to model if not already present. */ +export function applyExtendedContextSuffix(model: string): string { + if (!model) return model; + if (hasExtendedContextSuffix(model)) return model; + return `${model}${EXTENDED_CONTEXT_SUFFIX}`; +} + +/** Strip [1m] suffix from model string. */ +export function stripExtendedContextSuffix(model: string): string { + if (!model) return model; + return hasExtendedContextSuffix(model) ? model.slice(0, -EXTENDED_CONTEXT_SUFFIX.length) : model; +} diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts new file mode 100644 index 00000000..0ea40972 --- /dev/null +++ b/src/utils/claude-config-path.ts @@ -0,0 +1,26 @@ +import * as os from 'os'; +import * as path from 'path'; + +/** + * Resolve Claude config directory with test/dev overrides. + * Precedence: + * 1. CLAUDE_CONFIG_DIR (explicit override) + * 2. CCS_HOME compatibility path (/.claude) + * 3. ~/.claude (default) + */ +export function getClaudeConfigDir(): string { + if (process.env.CLAUDE_CONFIG_DIR) { + return path.resolve(process.env.CLAUDE_CONFIG_DIR); + } + + if (process.env.CCS_HOME) { + return path.join(path.dirname(path.resolve(process.env.CCS_HOME)), '.claude'); + } + + return path.join(os.homedir(), '.claude'); +} + +/** Resolve Claude settings.json path. */ +export function getClaudeSettingsPath(): string { + return path.join(getClaudeConfigDir(), 'settings.json'); +} diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index cc2754ce..b7d6c092 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -8,30 +8,15 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { info, warn } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getCcsHooksDir } from '../config-manager'; +import { getClaudeSettingsPath } from '../claude-config-path'; import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; // Hook file name const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; -/** - * Get Claude settings path (respects CCS_HOME for test isolation) - * In tests, returns path under CCS_HOME; in production, uses real ~/.claude/ - */ -function getClaudeSettingsPath(): string { - const ccsHome = process.env.CCS_HOME; - if (ccsHome) { - // Test mode: use CCS_HOME parent for .claude directory - // This prevents tests from modifying user's real settings - return path.join(path.dirname(ccsHome), '.claude', 'settings.json'); - } - // Production: use real home directory - return path.join(os.homedir(), '.claude', 'settings.json'); -} - // Buffer time added to max provider timeout for hook timeout (seconds) const HOOK_TIMEOUT_BUFFER = 30; diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index 41432e7b..f0f4a39f 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -14,7 +14,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as readline from 'readline'; -import * as os from 'os'; +import { getClaudeConfigDir } from '../utils/claude-config-path'; // ============================================================================ // TYPE DEFINITIONS @@ -176,8 +176,7 @@ export async function parseProjectDirectory(projectDir: string): Promise = { - agy: { - displayName: 'Antigravity', - description: 'Antigravity AI models', - }, - claude: { - displayName: 'Claude (Anthropic)', - description: 'Claude Opus/Sonnet models', - }, - gemini: { - displayName: 'Google Gemini', - description: 'Gemini Pro/Flash models', - }, - codex: { - displayName: 'OpenAI Codex', - description: 'GPT-4 and codex models', - }, - qwen: { - displayName: 'Alibaba Qwen', - description: 'Qwen Code models', - }, - iflow: { - displayName: 'iFlow', - description: 'iFlow AI models', - }, - kiro: { - displayName: 'Kiro (AWS)', - description: 'AWS CodeWhisperer models', - }, - ghcp: { - displayName: 'GitHub Copilot (OAuth)', - description: 'GitHub Copilot via OAuth', - }, - kimi: { - displayName: 'Kimi (Moonshot)', - description: 'Moonshot AI K2/K2.5 models', - }, -}; +export const PROVIDER_METADATA: Record = Object.freeze( + Object.fromEntries( + CLIPROXY_PROVIDERS.map((provider) => [ + provider, + { + displayName: PROVIDER_CAPABILITIES[provider].displayName, + description: PROVIDER_CAPABILITIES[provider].description, + }, + ]) + ) as Record +); // Map provider names to asset filenames (only providers with actual logos) export const PROVIDER_ASSETS: Record = { @@ -149,13 +113,12 @@ export const PROVIDER_COLORS: Record = { vertex: '#4285F4', iflow: '#f94144', qwen: '#6236FF', - kiro: '#4d908e', // Dark Cyan (AWS-inspired) - ghcp: '#43aa8b', // Seaweed (GitHub-inspired) - claude: '#D97757', // Anthropic brand color (matches SVG) - kimi: '#FF6B35', // Moonshot AI brand orange + kiro: '#4d908e', + ghcp: '#43aa8b', + claude: '#D97757', + kimi: '#FF6B35', }; -// Provider display names const PROVIDER_NAMES: Record = { ...Object.fromEntries( CLIPROXY_PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName]) @@ -163,7 +126,6 @@ const PROVIDER_NAMES: Record = { vertex: 'Vertex AI', }; -// Map provider to display name export function getProviderDisplayName(provider: unknown): string { const normalized = normalizeProviderInput(provider); if (!normalized) { @@ -181,9 +143,10 @@ export function getProviderDescription(provider: unknown): string { /** * Providers that use Device Code OAuth flow instead of Authorization Code flow. - * Device Code flow requires displaying a user code for manual entry at provider's website. */ -export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro', 'qwen', 'kimi']; +export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = [ + ...getProvidersByOAuthFlow('device_code'), +]; const DEVICE_CODE_PROVIDER_DISPLAY_NAMES: Readonly>> = Object.freeze({