From 8d2ec861551a68d30442a2e548e08578d511cb49 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 01:31:16 +0700 Subject: [PATCH 1/4] 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({ From af6aa2d7b2b40f144b7e53b3f69a63e92cbb10c6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 01:35:58 +0700 Subject: [PATCH 2/4] fix(types): use type-only import for composite tier config --- src/cliproxy/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index eaa9a016..925ee534 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -3,7 +3,7 @@ * Types for CLIProxyAPI binary management and execution */ -import { CompositeTierConfig } from '../config/unified-config-types'; +import type { CompositeTierConfig } from '../config/unified-config-types'; /** * Supported operating systems From 6074fcb0b628c801b3a579c2673f79e829270dc1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 01:44:44 +0700 Subject: [PATCH 3/4] fix(refactor): address PR review follow-up findings --- src/commands/cliproxy/index.ts | 56 ++++++++++----- tests/unit/commands/arg-extractor.test.ts | 83 +++++++++++++++++++++++ ui/src/lib/provider-config.ts | 3 + 3 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 tests/unit/commands/arg-extractor.test.ts diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index f592dc3f..0174e40c 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -7,6 +7,7 @@ import { CLIProxyBackend } from '../../cliproxy/types'; import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; +import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { handleSync } from '../cliproxy-sync-handler'; import { extractOption, hasAnyFlag } from '../arg-extractor'; @@ -76,8 +77,40 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend { * Returns the provider filter value and remaining args * Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all */ +type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp'; +type QuotaProviderFilter = QuotaProvider | 'all'; + +const PROVIDER_ARG_HELP_TEXT = 'agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'; + +const QUOTA_PROVIDER_ALIAS_MAP: Readonly> = { + 'gemini-cli': 'gemini', + 'github-copilot': 'ghcp', +}; + +const QUOTA_PROVIDER_IDS = Object.freeze( + CLIPROXY_PROVIDER_IDS.filter( + (provider): provider is QuotaProvider => + provider === 'agy' || provider === 'codex' || provider === 'gemini' || provider === 'ghcp' + ) +); + +const QUOTA_PROVIDER_SET = new Set(QUOTA_PROVIDER_IDS); + +function normalizeQuotaProvider(value: string): QuotaProviderFilter | null { + if (value === 'all') { + return 'all'; + } + + const normalized = QUOTA_PROVIDER_ALIAS_MAP[value] ?? value; + if (!QUOTA_PROVIDER_SET.has(normalized as QuotaProvider)) { + return null; + } + + return normalized as QuotaProvider; +} + function parseProviderArg(args: string[]): { - provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all'; + provider: QuotaProviderFilter; remainingArgs: string[]; } { const extracted = extractOption(args, ['--provider']); @@ -86,29 +119,18 @@ function parseProviderArg(args: string[]): { } if (extracted.missingValue || !extracted.value) { - console.error( - 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' - ); + console.error(`Warning: --provider requires a value. Valid options: ${PROVIDER_ARG_HELP_TEXT}`); return { provider: 'all', remainingArgs: extracted.remainingArgs }; } const value = extracted.value.toLowerCase(); - 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` - ); + const normalized = normalizeQuotaProvider(value); + if (!normalized) { + console.error(`Invalid provider '${value}'. Valid options: ${PROVIDER_ARG_HELP_TEXT}`); return { provider: 'all', remainingArgs: extracted.remainingArgs }; } return { - provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', + provider: normalized, remainingArgs: extracted.remainingArgs, }; } diff --git a/tests/unit/commands/arg-extractor.test.ts b/tests/unit/commands/arg-extractor.test.ts new file mode 100644 index 00000000..eeaa508d --- /dev/null +++ b/tests/unit/commands/arg-extractor.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'bun:test'; + +import { extractOption, hasAnyFlag } from '../../../src/commands/arg-extractor'; + +describe('arg-extractor', () => { + describe('extractOption', () => { + it('extracts --flag value and removes both tokens from remaining args', () => { + const result = extractOption(['--profile', 'gemini', '--yes'], ['--profile']); + + expect(result).toEqual({ + found: true, + value: 'gemini', + missingValue: false, + remainingArgs: ['--yes'], + }); + }); + + it('extracts --flag=value and removes inline token from remaining args', () => { + const result = extractOption(['--yes', '--profile=codex', 'prompt'], ['--profile']); + + expect(result).toEqual({ + found: true, + value: 'codex', + missingValue: false, + remainingArgs: ['--yes', 'prompt'], + }); + }); + + it('marks missing value when flag is last token', () => { + const result = extractOption(['prompt', '--profile'], ['--profile']); + + expect(result).toEqual({ + found: true, + missingValue: true, + remainingArgs: ['prompt'], + }); + }); + + it('marks missing value for empty inline value', () => { + const result = extractOption(['--profile=', '--yes'], ['--profile']); + + expect(result).toEqual({ + found: true, + missingValue: true, + remainingArgs: ['--yes'], + }); + }); + + it('marks missing value when next token is another flag and keeps that flag', () => { + const result = extractOption(['--profile', '--yes', 'prompt'], ['--profile']); + + expect(result).toEqual({ + found: true, + missingValue: true, + remainingArgs: ['--yes', 'prompt'], + }); + }); + + it('returns non-match state without altering args content', () => { + const args = ['--yes', 'prompt']; + const result = extractOption(args, ['--profile', '-p']); + + expect(result).toEqual({ + found: false, + missingValue: false, + remainingArgs: ['--yes', 'prompt'], + }); + expect(args).toEqual(['--yes', 'prompt']); + }); + }); + + describe('hasAnyFlag', () => { + it('returns true when any exact flag is present', () => { + expect(hasAnyFlag(['prompt', '--yes'], ['--yes', '-y'])).toBe(true); + expect(hasAnyFlag(['prompt', '-y'], ['--yes', '-y'])).toBe(true); + }); + + it('returns false when only non-matching or inline tokens exist', () => { + expect(hasAnyFlag(['prompt', '--yes=true'], ['--yes', '-y'])).toBe(false); + expect(hasAnyFlag(['prompt', '--profile=gemini'], ['--yes', '-y'])).toBe(false); + }); + }); +}); diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 59e98cde..26268eed 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -10,6 +10,9 @@ import { getProvidersByOAuthFlow, } from '../../../src/cliproxy/provider-capabilities'; +// Monorepo contract: UI consumes provider capability constants directly from backend +// to enforce one source of truth and prevent provider drift across surfaces. + /** Canonical list of CLIProxy provider IDs (shared with backend). */ export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS; From 343ec959fc8ea26c3e2b75d88613627cdad3b223 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 10:32:19 +0700 Subject: [PATCH 4/4] fix(core): resolve edge cases and hardcoded drift --- src/cliproxy/provider-capabilities.ts | 30 +++- src/commands/api-command.ts | 135 +++++++++++++++--- src/commands/arg-extractor.ts | 54 ++++++- src/commands/cliproxy/help-subcommand.ts | 3 +- src/commands/cliproxy/index.ts | 40 ++---- src/commands/cliproxy/quota-subcommand.ts | 126 ++++++++++------ src/commands/config-image-analysis-command.ts | 33 ++++- src/commands/persist-command.ts | 61 ++++++-- src/web-server/jsonl-parser.ts | 82 +++++++---- src/web-server/routes/persist-routes.ts | 83 ++++++----- src/web-server/routes/route-helpers.ts | 63 +++++++- .../cliproxy/extended-context-config.test.ts | 12 ++ .../cliproxy/provider-capabilities.test.ts | 26 ++++ tests/unit/commands/api-command-args.test.ts | 38 +++++ tests/unit/commands/arg-extractor.test.ts | 57 +++++++- .../config-image-analysis-command.test.ts | 29 +++- tests/unit/jsonl-parser.test.ts | 103 +++++++++++++ tests/unit/web-server/persist-routes.test.js | 53 ++----- tests/unit/web-server/route-helpers.test.ts | 94 ++++++++++++ 19 files changed, 894 insertions(+), 228 deletions(-) create mode 100644 tests/unit/commands/api-command-args.test.ts create mode 100644 tests/unit/web-server/route-helpers.test.ts diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index c582cfb7..02c5f3ea 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -140,6 +140,25 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze( Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[] ); +/** Providers currently supported by quota status fetchers. */ +export const QUOTA_SUPPORTED_PROVIDER_IDS = Object.freeze([ + 'agy', + 'codex', + 'gemini', + 'ghcp', +] as const); +export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDER_IDS)[number]; +const QUOTA_SUPPORTED_PROVIDER_SET = new Set(QUOTA_SUPPORTED_PROVIDER_IDS); + +export const QUOTA_PROVIDER_OPTION_VALUES = Object.freeze( + [ + ...QUOTA_SUPPORTED_PROVIDER_IDS, + ...QUOTA_SUPPORTED_PROVIDER_IDS.flatMap((provider) => PROVIDER_CAPABILITIES[provider].aliases), + 'all', + ].filter((value, index, values) => values.indexOf(value) === index) +); +export const QUOTA_PROVIDER_HELP_TEXT = QUOTA_PROVIDER_OPTION_VALUES.join(', '); + export function buildProviderMap( valueFor: (provider: CLIProxyProvider) => T ): Record { @@ -243,6 +262,15 @@ export function getProviderTokenTypeValues(provider: CLIProxyProvider): readonly } export function mapExternalProviderName(providerName: string): CLIProxyProvider | null { - const normalized = providerName.toLowerCase(); + const normalized = providerName.trim().toLowerCase(); + if (!normalized) { + return null; + } return PROVIDER_ALIAS_MAP.get(normalized) ?? null; } + +export function isQuotaSupportedProvider( + provider: CLIProxyProvider +): provider is QuotaSupportedProvider { + return QUOTA_SUPPORTED_PROVIDER_SET.has(provider as QuotaSupportedProvider); +} diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index fd15cdbd..495617d0 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -53,8 +53,14 @@ interface ApiCommandArgs { preset?: string; force?: boolean; yes?: boolean; + errors: string[]; } +const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; +const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset'] as const; +const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; +const API_VALUE_FLAG_SET = new Set(API_VALUE_FLAGS); + function sanitizeHelpText(value: string): string { return value .replace(/[\r\n\t]+/g, ' ') @@ -71,39 +77,129 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`; } +function applyRepeatedOption( + args: string[], + flags: readonly string[], + onValue: (value: string) => void, + onMissing: () => void +): string[] { + let remaining = [...args]; + + while (true) { + const extracted = extractOption(remaining, flags, { + allowDashValue: true, + knownFlags: API_KNOWN_FLAGS, + }); + if (!extracted.found) { + return remaining; + } + + if (extracted.missingValue || !extracted.value) { + onMissing(); + } else { + onValue(extracted.value); + } + + remaining = extracted.remainingArgs; + } +} + +function extractPositionalArgs(args: string[]): string[] { + const positionals: string[] = []; + + for (let i = 0; i < args.length; i++) { + const token = args[i]; + if (token === '--') { + positionals.push(...args.slice(i + 1)); + break; + } + + if (token.startsWith('-')) { + if (!token.includes('=') && API_VALUE_FLAG_SET.has(token)) { + const next = args[i + 1]; + if (next && !next.startsWith('-')) { + i++; + } + } + continue; + } + + positionals.push(token); + } + + return positionals; +} + /** Parse command line arguments for api commands */ -function parseArgs(args: string[]): ApiCommandArgs { +export function parseApiCommandArgs(args: string[]): ApiCommandArgs { const result: ApiCommandArgs = { force: hasAnyFlag(args, ['--force']), yes: hasAnyFlag(args, ['--yes', '-y']), + errors: [], }; let remaining = [...args]; - const baseUrl = extractOption(remaining, ['--base-url']); - if (baseUrl.value) result.baseUrl = baseUrl.value; - remaining = baseUrl.remainingArgs; + remaining = applyRepeatedOption( + remaining, + ['--base-url'], + (value) => { + result.baseUrl = value; + }, + () => { + result.errors.push('Missing value for --base-url'); + } + ); - const apiKey = extractOption(remaining, ['--api-key']); - if (apiKey.value) result.apiKey = apiKey.value; - remaining = apiKey.remainingArgs; + remaining = applyRepeatedOption( + remaining, + ['--api-key'], + (value) => { + result.apiKey = value; + }, + () => { + result.errors.push('Missing value for --api-key'); + } + ); - const model = extractOption(remaining, ['--model']); - if (model.value) result.model = model.value; - remaining = model.remainingArgs; + remaining = applyRepeatedOption( + remaining, + ['--model'], + (value) => { + result.model = value; + }, + () => { + result.errors.push('Missing value for --model'); + } + ); - const preset = extractOption(remaining, ['--preset']); - if (preset.value) result.preset = preset.value; - remaining = preset.remainingArgs; + remaining = applyRepeatedOption( + remaining, + ['--preset'], + (value) => { + result.preset = value; + }, + () => { + result.errors.push('Missing value for --preset'); + } + ); - result.name = remaining.find((arg) => !arg.startsWith('-')); + const positionalArgs = extractPositionalArgs(remaining); + result.name = positionalArgs[0]; return result; } /** Handle 'ccs api create' command */ async function handleCreate(args: string[]): Promise { await initUI(); - const parsedArgs = parseArgs(args); + const parsedArgs = parseApiCommandArgs(args); + + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => { + console.log(fail(errorMessage)); + }); + process.exit(1); + } console.log(header('Create API Profile')); console.log(''); @@ -389,7 +485,14 @@ async function handleList(): Promise { /** Handle 'ccs api remove' command */ async function handleRemove(args: string[]): Promise { await initUI(); - const parsedArgs = parseArgs(args); + const parsedArgs = parseApiCommandArgs(args); + + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => { + console.log(fail(errorMessage)); + }); + process.exit(1); + } const apis = getApiProfileNames(); diff --git a/src/commands/arg-extractor.ts b/src/commands/arg-extractor.ts index a4a45dcd..6c2709c3 100644 --- a/src/commands/arg-extractor.ts +++ b/src/commands/arg-extractor.ts @@ -9,17 +9,43 @@ export interface ExtractedOption { remainingArgs: string[]; } +export interface ExtractOptionOptions { + /** + * Allow values that start with "-" when they are not recognized flags. + * Useful for model IDs or other arbitrary strings. + */ + allowDashValue?: boolean; + /** + * Known flags for the current command. Used with allowDashValue to avoid + * treating a real flag token as a value. + */ + knownFlags?: readonly string[]; +} + function findInlineOption(arg: string, flag: string): string | undefined { const prefix = `${flag}=`; return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined; } +function isKnownFlagToken(token: string, knownFlags: readonly string[] | undefined): boolean { + if (!knownFlags || knownFlags.length === 0) { + return false; + } + + return knownFlags.some((flag) => token === flag || token.startsWith(`${flag}=`)); +} + /** * 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 { +export function extractOption( + args: string[], + flags: readonly string[], + options: ExtractOptionOptions = {} +): ExtractedOption { const remaining = [...args]; + const allowDashValue = options.allowDashValue ?? false; for (let i = 0; i < remaining.length; i++) { const token = remaining[i]; @@ -27,7 +53,14 @@ export function extractOption(args: string[], flags: readonly string[]): Extract for (const flag of flags) { if (token === flag) { const next = remaining[i + 1]; - if (!next || next.startsWith('-')) { + if (!next) { + remaining.splice(i, 1); + return { found: true, missingValue: true, remainingArgs: remaining }; + } + + const nextLooksLikeFlag = next.startsWith('-'); + const nextIsKnownFlag = isKnownFlagToken(next, options.knownFlags); + if (nextLooksLikeFlag && (!allowDashValue || nextIsKnownFlag)) { remaining.splice(i, 1); return { found: true, missingValue: true, remainingArgs: remaining }; } @@ -62,5 +95,20 @@ export function extractOption(args: string[], flags: readonly string[]): Extract /** 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)); + const truthyValues = new Set(['1', 'true', 'yes', 'on']); + return args.some((arg) => + flags.some((flag) => { + if (arg === flag) { + return true; + } + + const prefix = `${flag}=`; + if (!arg.startsWith(prefix)) { + return false; + } + + const value = arg.slice(prefix.length).trim().toLowerCase(); + return truthyValues.has(value); + }) + ); } diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 7f3219d9..9d4e41dd 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -11,6 +11,7 @@ import { getFallbackVersion, BACKEND_CONFIG, } from '../../cliproxy/platform-detector'; +import { QUOTA_PROVIDER_HELP_TEXT } from '../../cliproxy/provider-capabilities'; export async function showHelp(): Promise { await initUI(); @@ -55,7 +56,7 @@ export async function showHelp(): Promise { ['pause ', 'Pause account (skip in rotation)'], ['resume ', 'Resume paused account'], ['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'], - ['quota --provider ', 'Filter by provider (agy|codex|gemini|ghcp)'], + ['quota --provider ', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 0174e40c..b1735bc5 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -7,7 +7,12 @@ import { CLIProxyBackend } from '../../cliproxy/types'; import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; -import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities'; +import { + type QuotaSupportedProvider, + QUOTA_PROVIDER_HELP_TEXT, + mapExternalProviderName, + isQuotaSupportedProvider, +} from '../../cliproxy/provider-capabilities'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { handleSync } from '../cliproxy-sync-handler'; import { extractOption, hasAnyFlag } from '../arg-extractor'; @@ -75,38 +80,21 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend { /** * Parse --provider flag from args for quota command * Returns the provider filter value and remaining args - * Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all + * Accepts canonical + aliases from quota-supported providers, and `all` */ -type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp'; -type QuotaProviderFilter = QuotaProvider | 'all'; - -const PROVIDER_ARG_HELP_TEXT = 'agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'; - -const QUOTA_PROVIDER_ALIAS_MAP: Readonly> = { - 'gemini-cli': 'gemini', - 'github-copilot': 'ghcp', -}; - -const QUOTA_PROVIDER_IDS = Object.freeze( - CLIPROXY_PROVIDER_IDS.filter( - (provider): provider is QuotaProvider => - provider === 'agy' || provider === 'codex' || provider === 'gemini' || provider === 'ghcp' - ) -); - -const QUOTA_PROVIDER_SET = new Set(QUOTA_PROVIDER_IDS); +type QuotaProviderFilter = QuotaSupportedProvider | 'all'; function normalizeQuotaProvider(value: string): QuotaProviderFilter | null { if (value === 'all') { return 'all'; } - const normalized = QUOTA_PROVIDER_ALIAS_MAP[value] ?? value; - if (!QUOTA_PROVIDER_SET.has(normalized as QuotaProvider)) { + const canonicalProvider = mapExternalProviderName(value); + if (!canonicalProvider || !isQuotaSupportedProvider(canonicalProvider)) { return null; } - return normalized as QuotaProvider; + return canonicalProvider; } function parseProviderArg(args: string[]): { @@ -119,14 +107,16 @@ function parseProviderArg(args: string[]): { } if (extracted.missingValue || !extracted.value) { - console.error(`Warning: --provider requires a value. Valid options: ${PROVIDER_ARG_HELP_TEXT}`); + console.error( + `Warning: --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}` + ); return { provider: 'all', remainingArgs: extracted.remainingArgs }; } const value = extracted.value.toLowerCase(); const normalized = normalizeQuotaProvider(value); if (!normalized) { - console.error(`Invalid provider '${value}'. Valid options: ${PROVIDER_ARG_HELP_TEXT}`); + console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`); return { provider: 'all', remainingArgs: extracted.remainingArgs }; } return { diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index d3445377..a6ef643a 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -27,6 +27,10 @@ import type { } from '../../cliproxy/quota-types'; import { isOnCooldown } from '../../cliproxy/quota-manager'; import { CLIProxyProvider } from '../../cliproxy/types'; +import { + QUOTA_SUPPORTED_PROVIDER_IDS, + type QuotaSupportedProvider, +} from '../../cliproxy/provider-capabilities'; import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui'; interface CliproxyProfileArgs { @@ -481,65 +485,99 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes } } +interface QuotaProviderRuntime { + fetch: (verbose: boolean) => Promise; + hasData: (result: unknown) => boolean; + render: (result: unknown) => void; + emptyTitle: string; + emptyMessage: string; + authCommand: string; +} + +const QUOTA_PROVIDER_RUNTIME: Record = { + agy: { + fetch: (verbose) => fetchAllProviderQuotas('agy', verbose), + hasData: (result) => + (result as Awaited>).accounts.length > 0, + render: (result) => + displayAntigravityQuotaSection(result as Awaited>), + emptyTitle: 'Antigravity (0 accounts)', + emptyMessage: 'No Antigravity accounts configured', + authCommand: 'ccs agy --auth', + }, + codex: { + fetch: (verbose) => fetchAllCodexQuotas(verbose), + hasData: (result) => (result as { account: string; quota: CodexQuotaResult }[]).length > 0, + render: (result) => + displayCodexQuotaSection(result as { account: string; quota: CodexQuotaResult }[]), + emptyTitle: 'Codex (0 accounts)', + emptyMessage: 'No Codex accounts configured', + authCommand: 'ccs codex --auth', + }, + gemini: { + fetch: (verbose) => fetchAllGeminiCliQuotas(verbose), + hasData: (result) => (result as { account: string; quota: GeminiCliQuotaResult }[]).length > 0, + render: (result) => + displayGeminiCliQuotaSection(result as { account: string; quota: GeminiCliQuotaResult }[]), + emptyTitle: 'Gemini CLI (0 accounts)', + emptyMessage: 'No Gemini CLI accounts configured', + authCommand: 'ccs gemini --auth', + }, + ghcp: { + fetch: (verbose) => fetchAllGhcpQuotas(verbose), + hasData: (result) => (result as { account: string; quota: GhcpQuotaResult }[]).length > 0, + render: (result) => + displayGhcpQuotaSection(result as { account: string; quota: GhcpQuotaResult }[]), + emptyTitle: 'GitHub Copilot (0 accounts)', + emptyMessage: 'No GitHub Copilot accounts configured', + authCommand: 'ccs ghcp --auth', + }, +}; + export async function handleQuotaStatus( verbose = false, - providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all' + providerFilter: QuotaSupportedProvider | 'all' = 'all' ): Promise { await initUI(); console.log(header('Quota Status')); console.log(''); - const shouldFetch = { - agy: providerFilter === 'all' || providerFilter === 'agy', - codex: providerFilter === 'all' || providerFilter === 'codex', - gemini: providerFilter === 'all' || providerFilter === 'gemini', - ghcp: providerFilter === 'all' || providerFilter === 'ghcp', - }; + const requestedProviders = new Set( + providerFilter === 'all' ? QUOTA_SUPPORTED_PROVIDER_IDS : [providerFilter] + ); + const shouldFetch = (provider: QuotaSupportedProvider): boolean => + requestedProviders.has(provider); console.log(dim('Fetching quotas...')); - const [agyResults, codexResults, geminiResults, ghcpResults] = await Promise.all([ - shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null, - shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null, - shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null, - shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null, - ]); + const providerResults = new Map( + await Promise.all( + QUOTA_SUPPORTED_PROVIDER_IDS.map(async (provider) => { + if (!shouldFetch(provider)) { + return [provider, null] as const; + } + return [provider, await QUOTA_PROVIDER_RUNTIME[provider].fetch(verbose)] as const; + }) + ) + ); console.log(''); - if (agyResults && agyResults.accounts.length > 0) { - displayAntigravityQuotaSection(agyResults); - } else if (shouldFetch.agy) { - console.log(subheader('Antigravity (0 accounts)')); - console.log(info('No Antigravity accounts configured')); - console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); - console.log(''); - } + for (const provider of QUOTA_SUPPORTED_PROVIDER_IDS) { + if (!shouldFetch(provider)) { + continue; + } - if (codexResults && codexResults.length > 0) { - displayCodexQuotaSection(codexResults); - } else if (shouldFetch.codex) { - console.log(subheader('Codex (0 accounts)')); - console.log(info('No Codex accounts configured')); - console.log(` Run: ${color('ccs codex --auth', 'command')} to authenticate`); - console.log(''); - } + const runtime = QUOTA_PROVIDER_RUNTIME[provider]; + const result = providerResults.get(provider) ?? null; + if (result !== null && runtime.hasData(result)) { + runtime.render(result); + continue; + } - if (geminiResults && geminiResults.length > 0) { - displayGeminiCliQuotaSection(geminiResults); - } else if (shouldFetch.gemini) { - console.log(subheader('Gemini CLI (0 accounts)')); - console.log(info('No Gemini CLI accounts configured')); - console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`); - console.log(''); - } - - if (ghcpResults && ghcpResults.length > 0) { - displayGhcpQuotaSection(ghcpResults); - } else if (shouldFetch.ghcp) { - console.log(subheader('GitHub Copilot (0 accounts)')); - console.log(info('No GitHub Copilot accounts configured')); - console.log(` Run: ${color('ccs ghcp --auth', 'command')} to authenticate`); + console.log(subheader(runtime.emptyTitle)); + console.log(info(runtime.emptyMessage)); + console.log(` Run: ${color(runtime.authCommand, 'command')} to authenticate`); console.log(''); } } diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index 93c44602..41ec5df5 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -12,7 +12,11 @@ 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 { + CLIPROXY_PROVIDER_IDS, + PROVIDER_CAPABILITIES, + mapExternalProviderName, +} from '../cliproxy/provider-capabilities'; import { extractOption, hasAnyFlag } from './arg-extractor'; interface ImageAnalysisCommandOptions { @@ -20,9 +24,16 @@ interface ImageAnalysisCommandOptions { disable?: boolean; timeout?: number; setModel?: { provider: string; model: string }; + setModelError?: string; help?: boolean; } +const IMAGE_ANALYSIS_PROVIDER_ALIASES = Object.freeze( + CLIPROXY_PROVIDER_IDS.flatMap((provider) => PROVIDER_CAPABILITIES[provider].aliases).filter( + (alias, index, aliases) => aliases.indexOf(alias) === index + ) +); + function parseArgs(args: string[]): ImageAnalysisCommandOptions { const options: ImageAnalysisCommandOptions = { enable: hasAnyFlag(args, ['--enable']), @@ -46,6 +57,8 @@ function parseArgs(args: string[]): ImageAnalysisCommandOptions { const model = args[setModelIdx + 2]; if (provider && model && !provider.startsWith('-') && !model.startsWith('-')) { options.setModel = { provider, model }; + } else { + options.setModelError = '--set-model requires '; } } @@ -73,7 +86,10 @@ function showHelp(): void { console.log(''); console.log(subheader('Provider Models:')); - console.log(` ${dim('Providers with vision support: agy, gemini, codex, kiro, ghcp, claude')}`); + console.log(` ${dim(`Valid providers: ${CLIPROXY_PROVIDER_IDS.join(', ')}`)}`); + if (IMAGE_ANALYSIS_PROVIDER_ALIASES.length > 0) { + console.log(` ${dim(`Aliases accepted: ${IMAGE_ANALYSIS_PROVIDER_ALIASES.join(', ')}`)}`); + } console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`); console.log(''); @@ -160,6 +176,11 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< return; } + if (options.setModelError) { + console.error(fail(options.setModelError)); + process.exit(1); + } + // Validate conflicting flags (Edge case #2: --enable + --disable conflict) if (options.enable && options.disable) { console.error(fail('Cannot use --enable and --disable together')); @@ -188,9 +209,9 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< if (options.setModel) { const validProviders = [...CLIPROXY_PROVIDER_IDS]; - if ( - !validProviders.includes(options.setModel.provider as (typeof CLIPROXY_PROVIDER_IDS)[number]) - ) { + const normalizedProviderInput = options.setModel.provider.trim().toLowerCase(); + const canonicalProvider = mapExternalProviderName(normalizedProviderInput); + if (!canonicalProvider) { console.error(fail(`Invalid provider: ${options.setModel.provider}`)); console.error(info(`Valid providers: ${validProviders.join(', ')}`)); process.exit(1); @@ -203,7 +224,7 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< } imageConfig.provider_models = { ...imageConfig.provider_models, - [options.setModel.provider]: model, + [canonicalProvider]: model, }; hasChanges = true; } diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index eba20cc5..b02e41ed 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -10,6 +10,7 @@ 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, { @@ -58,7 +59,14 @@ function parseArgs(args: string[]): PersistCommandArgs { } function formatDisplayPath(filePath: string): string { + const defaultClaudeDir = path.join(os.homedir(), '.claude'); const claudeDir = getClaudeConfigDir(); + + // Keep real path when user overrides Claude directory. + if (path.resolve(claudeDir) !== path.resolve(defaultClaudeDir)) { + return filePath; + } + if (filePath === claudeDir) { return '~/.claude'; } @@ -71,6 +79,10 @@ function formatDisplayPath(filePath: string): string { return filePath; } +function getClaudeSettingsDisplayPath(): string { + return formatDisplayPath(getClaudeSettingsPath()); +} + /** Read existing Claude settings.json with validation */ function readClaudeSettings(): Record { const settingsPath = getClaudeSettingsPath(); @@ -175,6 +187,25 @@ interface BackupFile { date: Date; } +function parseBackupTimestamp(timestamp: string): Date | null { + const year = parseInt(timestamp.slice(0, 4), 10); + const month = parseInt(timestamp.slice(4, 6), 10); + const day = parseInt(timestamp.slice(6, 8), 10); + const hour = parseInt(timestamp.slice(9, 11), 10); + const minute = parseInt(timestamp.slice(11, 13), 10); + const second = parseInt(timestamp.slice(13, 15), 10); + const date = new Date(year, month - 1, day, hour, minute, second); + + if (date.getFullYear() !== year) return null; + if (date.getMonth() !== month - 1) return null; + if (date.getDate() !== day) return null; + if (date.getHours() !== hour) return null; + if (date.getMinutes() !== minute) return null; + if (date.getSeconds() !== second) return null; + + return date; +} + /** Get all backup files sorted by date (newest first) */ function getBackupFiles(): BackupFile[] { const settingsPath = getClaudeSettingsPath(); @@ -190,17 +221,12 @@ function getBackupFiles(): BackupFile[] { const match = f.match(backupPattern); if (!match) return null; const timestamp = match[1]; - // Parse YYYYMMDD_HHMMSS - const year = parseInt(timestamp.slice(0, 4)); - const month = parseInt(timestamp.slice(4, 6)) - 1; - const day = parseInt(timestamp.slice(6, 8)); - const hour = parseInt(timestamp.slice(9, 11)); - const min = parseInt(timestamp.slice(11, 13)); - const sec = parseInt(timestamp.slice(13, 15)); + const date = parseBackupTimestamp(timestamp); + if (!date) return null; return { path: path.join(dir, f), timestamp, - date: new Date(year, month, day, hour, min, sec), + date, }; }) .filter((f): f is BackupFile => f !== null) @@ -328,7 +354,7 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise console.log(`Backup: ${color(backup.timestamp, 'command')}`); console.log(`Date: ${backup.date.toLocaleString()}`); console.log(''); - console.log(warn('This will replace ~/.claude/settings.json')); + console.log(warn(`This will replace ${getClaudeSettingsDisplayPath()}`)); console.log(''); if (!yes) { const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false }); @@ -346,6 +372,11 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise process.exit(1); } } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + console.log(fail('Backup was deleted during restore')); + process.exit(1); + } console.log(fail(`Backup file is corrupted: ${(error as Error).message}`)); process.exit(1); } @@ -377,7 +408,7 @@ async function showHelp(): Promise { console.log(''); console.log(subheader('Description')); console.log(" Writes a profile's environment variables directly to"); - console.log(' ~/.claude/settings.json for native Claude Code usage.'); + console.log(` ${getClaudeSettingsDisplayPath()} for native Claude Code usage.`); console.log(''); console.log(' This allows Claude Code to use the profile without CCS,'); console.log(' enabling compatibility with IDEs and extensions.'); @@ -418,7 +449,9 @@ async function showHelp(): Promise { console.log(subheader('Notes')); console.log(' [i] CLIProxy profiles require the proxy to be running.'); console.log(' [i] Copilot profiles require copilot-api daemon.'); - console.log(' [i] Backups are saved as ~/.claude/settings.json.backup.YYYYMMDD_HHMMSS'); + console.log( + ` [i] Backups are saved as ${getClaudeSettingsDisplayPath()}.backup.YYYYMMDD_HHMMSS` + ); console.log(''); } @@ -478,7 +511,7 @@ export async function handlePersistCommand(args: string[]): Promise { console.log(''); console.log(`Profile type: ${color(resolved.profileType, 'command')}`); console.log(''); - console.log('The following env vars will be written to ~/.claude/settings.json:'); + console.log(`The following env vars will be written to ${getClaudeSettingsDisplayPath()}:`); console.log(''); // Display env vars (mask sensitive values) const envKeys = Object.keys(resolved.env); @@ -502,7 +535,7 @@ export async function handlePersistCommand(args: string[]): Promise { console.log(''); } // Warning about modification - console.log(warn('This will modify ~/.claude/settings.json')); + console.log(warn(`This will modify ${getClaudeSettingsDisplayPath()}`)); console.log(dim(' Existing hooks and other settings will be preserved.')); console.log(''); // Check if settings.json exists for backup @@ -570,7 +603,7 @@ export async function handlePersistCommand(args: string[]): Promise { process.exit(1); } console.log(''); - console.log(ok(`Profile '${parsedArgs.profile}' written to ~/.claude/settings.json`)); + console.log(ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`)); console.log(''); console.log(info('Claude Code will now use this profile by default.')); console.log(dim(' To revert, restore the backup or edit settings.json manually.')); diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index f0f4a39f..b6d2f74b 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -63,6 +63,9 @@ export interface ParserOptions { projectsDir?: string; } +const DEFAULT_SCAN_CONCURRENCY = 10; +const MAX_SCAN_CONCURRENCY = 64; + // ============================================================================ // CORE PARSING FUNCTIONS // ============================================================================ @@ -71,6 +74,14 @@ export interface ParserOptions { * Parse a single JSONL line into RawUsageEntry if valid * Returns null for non-assistant entries or entries without usage data */ +function toNonNegativeNumber(value: unknown): number { + const numeric = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numeric) || numeric < 0) { + return 0; + } + return numeric; +} + export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null { // Strip UTF-8 BOM if present (can occur on first line of some files) const cleanLine = line.replace(/^\uFEFF/, '').trim(); @@ -88,10 +99,10 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr const assistant = entry as JsonlAssistantEntry; return { - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheCreationTokens: usage.cache_creation_input_tokens || 0, - cacheReadTokens: usage.cache_read_input_tokens || 0, + inputTokens: toNonNegativeNumber(usage.input_tokens), + outputTokens: toNonNegativeNumber(usage.output_tokens), + cacheCreationTokens: toNonNegativeNumber(usage.cache_creation_input_tokens), + cacheReadTokens: toNonNegativeNumber(usage.cache_read_input_tokens), model: assistant.message.model, sessionId: assistant.sessionId || '', timestamp: assistant.timestamp || new Date().toISOString(), @@ -118,38 +129,48 @@ export async function parseJsonlFile( ): Promise { const entries: RawUsageEntry[] = []; - if (!fs.existsSync(filePath)) { - return entries; - } + let fileStream: fs.ReadStream | null = null; + let rl: readline.Interface | null = null; + try { + fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); - const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - - for await (const line of rl) { - const entry = parseUsageEntry(line, projectPath); - if (entry) { - entries.push(entry); + for await (const line of rl) { + const entry = parseUsageEntry(line, projectPath); + if (entry) { + entries.push(entry); + } } + } catch { + // File read/stream error - return whatever was parsed so far + } finally { + rl?.close(); + fileStream?.destroy(); } return entries; } +function decodeProjectPath(projectDir: string): string { + const raw = path.basename(projectDir).replace(/-/g, '/'); + const safeSegments = raw + .split('/') + .filter((segment) => segment && segment !== '.' && segment !== '..'); + + return `/${safeSegments.join('/')}`; +} + /** * Parse all JSONL files in a single project directory */ export async function parseProjectDirectory(projectDir: string): Promise { const entries: RawUsageEntry[] = []; - if (!fs.existsSync(projectDir)) { - return entries; - } - // Get project path from directory name (e.g., "-home-kai-project" -> "/home/kai/project") - const projectPath = path.basename(projectDir).replace(/-/g, '/'); + const projectPath = decodeProjectPath(projectDir); try { const files = await fs.promises.readdir(projectDir); @@ -185,10 +206,6 @@ export function getDefaultProjectsDir(): string { export function findProjectDirectories(projectsDir?: string): string[] { const dir = projectsDir || getDefaultProjectsDir(); - if (!fs.existsSync(dir)) { - return []; - } - try { const entries = fs.readdirSync(dir, { withFileTypes: true }); return entries @@ -207,7 +224,14 @@ export function findProjectDirectories(projectsDir?: string): string[] { * @returns All parsed usage entries from all projects */ export async function scanProjectsDirectory(options: ParserOptions = {}): Promise { - const { concurrency = 10, projectsDir } = options; + const requestedConcurrency = options.concurrency; + const concurrency = + typeof requestedConcurrency === 'number' && + Number.isInteger(requestedConcurrency) && + requestedConcurrency > 0 + ? Math.min(requestedConcurrency, MAX_SCAN_CONCURRENCY) + : DEFAULT_SCAN_CONCURRENCY; + const { projectsDir } = options; const allEntries: RawUsageEntry[] = []; const projectDirs = findProjectDirectories(projectsDir); @@ -230,8 +254,8 @@ export async function scanProjectsDirectory(options: ParserOptions = {}): Promis if (options.minDate) { const minTime = options.minDate.getTime(); return allEntries.filter((entry) => { - const entryTime = new Date(entry.timestamp).getTime(); - return entryTime >= minTime; + const entryTime = Date.parse(entry.timestamp); + return Number.isFinite(entryTime) && entryTime >= minTime; }); } diff --git a/src/web-server/routes/persist-routes.ts b/src/web-server/routes/persist-routes.ts index 76f0cb60..ebc9ec3a 100644 --- a/src/web-server/routes/persist-routes.ts +++ b/src/web-server/routes/persist-routes.ts @@ -28,39 +28,28 @@ interface BackupFile { /** * Async mutex for restore operations - prevents race conditions * - * Design: Uses a Promise queue pattern for atomic lock acquisition. - * When the mutex is locked, subsequent callers are added to a queue - * and immediately receive `false` when released, signaling they should - * return a 409 Conflict rather than wait. This prevents request pileup - * while ensuring only one restore can execute at a time. + * Design: Fast-fail lock. + * If a restore is already running, callers immediately get `false` + * and the route returns HTTP 409. This avoids request pileup. */ class RestoreMutex { private locked = false; - private queue: Array<() => void> = []; /** * Attempt to acquire the mutex - * @returns true if acquired, false if already locked (queued request) + * @returns true if acquired, false if already locked */ async acquire(): Promise { if (this.locked) { - // Already locked - add to queue and wait - return new Promise((resolve) => { - this.queue.push(() => resolve(false)); // Return false = was queued, reject - }); + return false; } this.locked = true; return true; } - /** Release the mutex, signaling next queued request (if any) to fail */ + /** Release the mutex */ release(): void { - const next = this.queue.shift(); - if (next) { - next(); // Signal queued request to fail - } else { - this.locked = false; - } + this.locked = false; } } @@ -76,6 +65,25 @@ function isSymlink(filePath: string): boolean { } } +function parseBackupTimestamp(timestamp: string): Date | null { + const year = parseInt(timestamp.slice(0, 4), 10); + const month = parseInt(timestamp.slice(4, 6), 10); + const day = parseInt(timestamp.slice(6, 8), 10); + const hour = parseInt(timestamp.slice(9, 11), 10); + const minute = parseInt(timestamp.slice(11, 13), 10); + const second = parseInt(timestamp.slice(13, 15), 10); + const date = new Date(year, month - 1, day, hour, minute, second); + + if (date.getFullYear() !== year) return null; + if (date.getMonth() !== month - 1) return null; + if (date.getDate() !== day) return null; + if (date.getHours() !== hour) return null; + if (date.getMinutes() !== minute) return null; + if (date.getSeconds() !== second) return null; + + return date; +} + /** Get all backup files sorted by date (newest first) */ function getBackupFiles(): BackupFile[] { const settingsPath = getClaudeSettingsPath(); @@ -91,16 +99,12 @@ function getBackupFiles(): BackupFile[] { const match = f.match(backupPattern); if (!match) return null; const timestamp = match[1]; - const year = parseInt(timestamp.slice(0, 4)); - const month = parseInt(timestamp.slice(4, 6)) - 1; - const day = parseInt(timestamp.slice(6, 8)); - const hour = parseInt(timestamp.slice(9, 11)); - const min = parseInt(timestamp.slice(11, 13)); - const sec = parseInt(timestamp.slice(13, 15)); + const date = parseBackupTimestamp(timestamp); + if (!date) return null; return { path: path.join(dir, f), timestamp, - date: new Date(year, month, day, hour, min, sec), + date, }; }) .filter((f): f is BackupFile => f !== null) @@ -178,16 +182,18 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response): let backupContent: string; let fd: number | undefined; try { - // Verify not symlink immediately before open - const stats = fs.lstatSync(backup.path); - if (stats.isSymbolicLink()) { - res - .status(400) - .json({ error: 'Backup became symlink during read - refusing for security' }); + if (typeof fs.constants.O_NOFOLLOW !== 'number') { + res.status(500).json({ error: 'Secure restore unsupported on this platform' }); return; } // Open file descriptor for atomic read - fd = fs.openSync(backup.path, 'r'); + const openFlags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + fd = fs.openSync(backup.path, openFlags); + const stats = fs.fstatSync(fd); + if (!stats.isFile()) { + res.status(400).json({ error: 'Backup path is not a regular file' }); + return; + } const buffer = Buffer.alloc(stats.size); fs.readSync(fd, buffer, 0, stats.size, 0); backupContent = buffer.toString('utf8'); @@ -199,6 +205,10 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response): } } catch (err) { const error = err as NodeJS.ErrnoException; + if (error.code === 'ELOOP') { + res.status(400).json({ error: 'Backup file is a symlink - refusing for security' }); + return; + } if (error.code === 'ENOENT') { res.status(404).json({ error: 'Backup was deleted during restore' }); return; @@ -217,17 +227,18 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response): // Atomic restore with rollback capability const settingsDir = path.dirname(settingsPath); - const tempPath = path.join(settingsDir, 'settings.json.restore-tmp'); - const rollbackPath = path.join(settingsDir, 'settings.json.rollback-tmp'); + const restoreNonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const tempPath = path.join(settingsDir, `settings.json.restore-${restoreNonce}.tmp`); + const rollbackPath = path.join(settingsDir, `settings.json.rollback-${restoreNonce}.tmp`); try { // Step 1: Backup current settings for rollback if (fs.existsSync(settingsPath)) { - fs.copyFileSync(settingsPath, rollbackPath); + fs.copyFileSync(settingsPath, rollbackPath, fs.constants.COPYFILE_EXCL); } // Step 2: Write validated content to temp file - fs.writeFileSync(tempPath, backupContent, 'utf8'); + fs.writeFileSync(tempPath, backupContent, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); // Step 3: Atomic rename (replaces existing file) fs.renameSync(tempPath, settingsPath); diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 82f00788..076a12d7 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -161,21 +161,69 @@ export function updateSettingsFile( * - ~/.ccs/ directory: read/write allowed * - ~/.claude/settings.json: read-only */ +function normalizePathForComparison(filePath: string): string { + const normalized = path.resolve(path.normalize(filePath)); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function isPathWithin(basePath: string, targetPath: string): boolean { + const relative = path.relative(basePath, targetPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function isSymlinkPath(filePath: string): boolean { + try { + return fs.lstatSync(filePath).isSymbolicLink(); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT' || nodeError.code === 'ENOTDIR') { + return false; + } + return true; + } +} + +function hasSymlinkSegment(basePath: string, targetPath: string): boolean { + const relative = path.relative(basePath, targetPath); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + return false; + } + + let currentPath = basePath; + const segments = relative.split(path.sep).filter(Boolean); + for (const segment of segments) { + currentPath = path.join(currentPath, segment); + if (isSymlinkPath(currentPath)) { + return true; + } + } + + return false; +} + export function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string; } { const expandedPath = expandPath(filePath); - const normalizedPath = path.normalize(expandedPath); - const ccsDir = getCcsDir(); - const claudeSettingsPath = path.normalize(getClaudeSettingsPath()); + const resolvedPath = path.resolve(path.normalize(expandedPath)); + const resolvedCcsDir = path.resolve(path.normalize(getCcsDir())); + const resolvedClaudeSettingsPath = path.resolve(path.normalize(getClaudeSettingsPath())); + const normalizedPath = normalizePathForComparison(resolvedPath); + const ccsDir = normalizePathForComparison(resolvedCcsDir); + const claudeSettingsPath = normalizePathForComparison(resolvedClaudeSettingsPath); // Check if path is within ~/.ccs/ - if (normalizedPath.startsWith(ccsDir)) { + if (isPathWithin(ccsDir, normalizedPath)) { + if (hasSymlinkSegment(resolvedCcsDir, resolvedPath)) { + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; + } + // Block access to sensitive subdirectories - const relativePath = normalizedPath.slice(ccsDir.length); - if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) { + const relativePath = path.relative(ccsDir, normalizedPath); + const pathSegments = relativePath.split(path.sep).filter(Boolean); + if (pathSegments.includes('.git') || pathSegments.includes('node_modules')) { return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; } return { valid: true, readonly: false }; @@ -183,6 +231,9 @@ export function validateFilePath(filePath: string): { // Allow read-only access to ~/.claude/settings.json if (normalizedPath === claudeSettingsPath) { + if (isSymlinkPath(resolvedClaudeSettingsPath)) { + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; + } return { valid: true, readonly: true }; } diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts index 697ba35e..17e93a10 100644 --- a/tests/unit/cliproxy/extended-context-config.test.ts +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -144,6 +144,14 @@ describe('applyExtendedContextConfig', () => { expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]'); }); + it('handles model IDs that already include both thinking and [1m] suffixes', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gemini-2.5-pro(high)[1m]', + }; + applyExtendedContextConfig(env, 'gemini', undefined); + expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]'); + }); + it('handles empty env vars gracefully', () => { const env: NodeJS.ProcessEnv = {}; applyExtendedContextConfig(env, 'gemini', undefined); @@ -164,8 +172,12 @@ describe('applyExtendedContextConfig', () => { 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]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-2.5-pro[1m]', }; applyExtendedContextConfig(env, 'gemini', false); expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gemini-2.5-pro'); }); }); diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index bbc8ae8b..3e9a606f 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -9,6 +9,10 @@ import { getProvidersByOAuthFlow, isCLIProxyProvider, mapExternalProviderName, + QUOTA_SUPPORTED_PROVIDER_IDS, + isQuotaSupportedProvider, + QUOTA_PROVIDER_OPTION_VALUES, + QUOTA_PROVIDER_HELP_TEXT, } from '../../../src/cliproxy/provider-capabilities'; import { OAUTH_CALLBACK_PORTS as DIAGNOSTIC_CALLBACK_PORTS, @@ -63,9 +67,31 @@ describe('provider-capabilities', () => { expect(mapExternalProviderName('github-copilot')).toBe('ghcp'); expect(mapExternalProviderName('copilot')).toBe('ghcp'); expect(mapExternalProviderName('anthropic')).toBe('claude'); + expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp'); + expect(mapExternalProviderName('')).toBeNull(); expect(mapExternalProviderName('unknown-provider')).toBeNull(); }); + it('exposes quota-supported providers and guards correctly', () => { + expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'gemini', 'ghcp']); + expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([ + 'agy', + 'codex', + 'gemini', + 'ghcp', + 'antigravity', + 'gemini-cli', + 'github-copilot', + 'copilot', + 'all', + ]); + expect(QUOTA_PROVIDER_HELP_TEXT).toBe( + 'agy, codex, gemini, ghcp, antigravity, gemini-cli, github-copilot, copilot, all' + ); + expect(isQuotaSupportedProvider('ghcp')).toBe(true); + expect(isQuotaSupportedProvider('kiro')).toBe(false); + }); + it('exposes callback port and display name capabilities', () => { expect(getOAuthCallbackPort('qwen')).toBeNull(); expect(getOAuthCallbackPort('kiro')).toBeNull(); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts new file mode 100644 index 00000000..b8a0dc82 --- /dev/null +++ b/tests/unit/commands/api-command-args.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseApiCommandArgs } from '../../../src/commands/api-command'; + +describe('api-command arg parser', () => { + test('keeps positional API name when boolean flags precede it', () => { + const parsed = parseApiCommandArgs(['--yes', 'my-api']); + + expect(parsed.yes).toBe(true); + expect(parsed.name).toBe('my-api'); + }); + + test('uses last value when repeated value flags are provided', () => { + const parsed = parseApiCommandArgs([ + 'profile-a', + '--model', + 'claude-3-5-sonnet', + '--model=claude-3-7-sonnet', + ]); + + expect(parsed.name).toBe('profile-a'); + expect(parsed.model).toBe('claude-3-7-sonnet'); + expect(parsed.errors).toEqual([]); + }); + + test('collects missing-value errors for required option values', () => { + const parsed = parseApiCommandArgs(['profile-a', '--base-url', '--api-key']); + + expect(parsed.errors).toEqual(['Missing value for --base-url', 'Missing value for --api-key']); + }); + + test('supports option terminator for positional args that look like flags', () => { + const parsed = parseApiCommandArgs(['--yes', '--', '-my-api']); + + expect(parsed.yes).toBe(true); + expect(parsed.name).toBe('-my-api'); + }); +}); diff --git a/tests/unit/commands/arg-extractor.test.ts b/tests/unit/commands/arg-extractor.test.ts index eeaa508d..b65d6862 100644 --- a/tests/unit/commands/arg-extractor.test.ts +++ b/tests/unit/commands/arg-extractor.test.ts @@ -56,6 +56,53 @@ describe('arg-extractor', () => { }); }); + it('accepts dash-prefixed value when allowDashValue is enabled', () => { + const result = extractOption(['--model', '-preview', '--yes'], ['--model'], { + allowDashValue: true, + knownFlags: ['--model', '--yes'], + }); + + expect(result).toEqual({ + found: true, + value: '-preview', + missingValue: false, + remainingArgs: ['--yes'], + }); + }); + + it('still treats known flags as missing when allowDashValue is enabled', () => { + const result = extractOption(['--model', '--yes', 'prompt'], ['--model'], { + allowDashValue: true, + knownFlags: ['--model', '--yes'], + }); + + expect(result).toEqual({ + found: true, + missingValue: true, + remainingArgs: ['--yes', 'prompt'], + }); + }); + + it('supports repeated extraction loops with deterministic last-value wins behavior', () => { + let remaining = ['--model', 'gpt-4.1-mini', '--model', 'gpt-4.1']; + let selected: string | undefined; + + while (true) { + const extracted = extractOption(remaining, ['--model']); + if (!extracted.found) { + break; + } + + if (!extracted.missingValue && extracted.value) { + selected = extracted.value; + } + remaining = extracted.remainingArgs; + } + + expect(selected).toBe('gpt-4.1'); + expect(remaining).toEqual([]); + }); + it('returns non-match state without altering args content', () => { const args = ['--yes', 'prompt']; const result = extractOption(args, ['--profile', '-p']); @@ -75,8 +122,14 @@ describe('arg-extractor', () => { expect(hasAnyFlag(['prompt', '-y'], ['--yes', '-y'])).toBe(true); }); - it('returns false when only non-matching or inline tokens exist', () => { - expect(hasAnyFlag(['prompt', '--yes=true'], ['--yes', '-y'])).toBe(false); + it('supports inline truthy values for boolean flags', () => { + expect(hasAnyFlag(['prompt', '--yes=true'], ['--yes', '-y'])).toBe(true); + expect(hasAnyFlag(['prompt', '--yes=1'], ['--yes', '-y'])).toBe(true); + expect(hasAnyFlag(['prompt', '--yes=on'], ['--yes', '-y'])).toBe(true); + }); + + it('returns false for non-truthy or unrelated inline tokens', () => { + expect(hasAnyFlag(['prompt', '--yes=false'], ['--yes', '-y'])).toBe(false); expect(hasAnyFlag(['prompt', '--profile=gemini'], ['--yes', '-y'])).toBe(false); }); }); diff --git a/tests/unit/commands/config-image-analysis-command.test.ts b/tests/unit/commands/config-image-analysis-command.test.ts index b31af9f2..6a7f9134 100644 --- a/tests/unit/commands/config-image-analysis-command.test.ts +++ b/tests/unit/commands/config-image-analysis-command.test.ts @@ -117,7 +117,17 @@ image_analysis: describe('provider validation', () => { it('should accept valid providers', () => { - const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + const validProviders = [ + 'agy', + 'gemini', + 'codex', + 'kiro', + 'ghcp', + 'claude', + 'qwen', + 'iflow', + 'kimi', + ]; for (const provider of validProviders) { expect(validProviders.includes(provider)).toBe(true); @@ -125,7 +135,17 @@ image_analysis: }); it('should reject invalid providers', () => { - const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + const validProviders = [ + 'agy', + 'gemini', + 'codex', + 'kiro', + 'ghcp', + 'claude', + 'qwen', + 'iflow', + 'kimi', + ]; const invalidProviders = ['unknown', 'custom', 'my-provider', 'test']; for (const provider of invalidProviders) { @@ -147,12 +167,15 @@ image_analysis: kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', claude: 'claude-haiku-4-5-20251001', + qwen: 'vision-model', + iflow: 'qwen3-vl-plus', + kimi: 'vision-model', }, }; expect(defaultConfig.enabled).toBe(true); expect(defaultConfig.timeout).toBe(60); - expect(Object.keys(defaultConfig.provider_models).length).toBe(6); + expect(Object.keys(defaultConfig.provider_models).length).toBe(9); }); }); diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index b3c00e71..ce8b1e6f 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -167,6 +167,28 @@ describe('parseUsageEntry', () => { expect(result).not.toBeNull(); expect(result!.target).toBeUndefined(); }); + + test('coerces token fields to non-negative numbers', () => { + const withInvalidUsage = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + message: { + model: 'claude-sonnet-4-5', + usage: { + input_tokens: '1500', + output_tokens: -10, + cache_creation_input_tokens: 'bad', + cache_read_input_tokens: null, + }, + }, + }); + + const result = parseUsageEntry(withInvalidUsage, '/test'); + expect(result).not.toBeNull(); + expect(result!.inputTokens).toBe(1500); + expect(result!.outputTokens).toBe(0); + expect(result!.cacheCreationTokens).toBe(0); + expect(result!.cacheReadTokens).toBe(0); + }); }); // ============================================================================ @@ -217,6 +239,14 @@ describe('parseJsonlFile', () => { expect(entries.length).toBe(0); }); + test('returns empty array when stream cannot be opened', async () => { + const directoryPath = path.join(tempDir, 'not-a-file'); + fs.mkdirSync(directoryPath); + + const entries = await parseJsonlFile(directoryPath, '/test'); + expect(entries).toEqual([]); + }); + test('handles file with blank lines', async () => { const filePath = path.join(tempDir, 'blanks.jsonl'); const content = ['', VALID_ASSISTANT_ENTRY, '', ' ', ASSISTANT_ENTRY_NO_CACHE, ''].join('\n'); @@ -280,6 +310,17 @@ describe('parseProjectDirectory', () => { readdirSpy.mockRestore(); } }); + + test('sanitizes derived projectPath from dashed directory names', async () => { + const projectDir = path.join(tempDir, '-..-etc-passwd'); + fs.mkdirSync(projectDir); + fs.writeFileSync(path.join(projectDir, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + + const entries = await parseProjectDirectory(projectDir); + + expect(entries.length).toBe(1); + expect(entries[0].projectPath).toBe('/etc/passwd'); + }); }); describe('findProjectDirectories', () => { @@ -395,6 +436,33 @@ describe('scanProjectsDirectory', () => { expect(entries[0].sessionId).toBe('new'); }); + test('skips entries with invalid timestamps when minDate filtering is enabled', async () => { + const project = path.join(tempDir, '-test-invalid-timestamp'); + fs.mkdirSync(project); + + const invalidTimestampEntry = JSON.stringify({ + type: 'assistant', + sessionId: 'invalid-time', + timestamp: 'not-a-date', + message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 100, output_tokens: 50 } }, + }); + const validEntry = JSON.stringify({ + type: 'assistant', + sessionId: 'valid-time', + timestamp: '2025-12-09T00:00:00.000Z', + message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 200, output_tokens: 100 } }, + }); + fs.writeFileSync(path.join(project, 'session.jsonl'), [invalidTimestampEntry, validEntry].join('\n')); + + const entries = await scanProjectsDirectory({ + projectsDir: tempDir, + minDate: new Date('2025-01-01'), + }); + + expect(entries.length).toBe(1); + expect(entries[0].sessionId).toBe('valid-time'); + }); + test('returns empty array for empty directory', async () => { const entries = await scanProjectsDirectory({ projectsDir: tempDir }); expect(entries.length).toBe(0); @@ -416,6 +484,41 @@ describe('scanProjectsDirectory', () => { expect(entries.length).toBe(5); }); + + test('falls back to default concurrency when invalid concurrency is provided', async () => { + for (let i = 0; i < 3; i++) { + const project = path.join(tempDir, `-invalid-concurrency-${i}`); + fs.mkdirSync(project); + fs.writeFileSync(path.join(project, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + } + + const zeroEntries = await scanProjectsDirectory({ + projectsDir: tempDir, + concurrency: 0, + }); + expect(zeroEntries.length).toBe(3); + + const negativeEntries = await scanProjectsDirectory({ + projectsDir: tempDir, + concurrency: -5, + }); + expect(negativeEntries.length).toBe(3); + }); + + test('caps very high concurrency values to a safe maximum', async () => { + for (let i = 0; i < 4; i++) { + const project = path.join(tempDir, `-capped-concurrency-${i}`); + fs.mkdirSync(project); + fs.writeFileSync(path.join(project, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + } + + const entries = await scanProjectsDirectory({ + projectsDir: tempDir, + concurrency: 9999, + }); + + expect(entries.length).toBe(4); + }); }); // ============================================================================ diff --git a/tests/unit/web-server/persist-routes.test.js b/tests/unit/web-server/persist-routes.test.js index 3542be53..7060de05 100644 --- a/tests/unit/web-server/persist-routes.test.js +++ b/tests/unit/web-server/persist-routes.test.js @@ -235,26 +235,18 @@ describe('Persist Routes', function () { class RestoreMutex { constructor() { this.locked = false; - this.queue = []; } async acquire() { if (this.locked) { - return new Promise((resolve) => { - this.queue.push(() => resolve(false)); - }); + return false; } this.locked = true; return true; } release() { - const next = this.queue.shift(); - if (next) { - next(); - } else { - this.locked = false; - } + this.locked = false; } } @@ -266,21 +258,16 @@ describe('Persist Routes', function () { assert.strictEqual(mutex.locked, true); }); - it('should queue and reject concurrent requests', async function () { + it('should reject concurrent requests while locked', async function () { const mutex = new RestoreMutex(); // First acquire succeeds const first = await mutex.acquire(); assert.strictEqual(first, true); - // Second acquire queues and gets false when released - const secondPromise = mutex.acquire(); - - // Release the mutex - mutex.release(); - - const second = await secondPromise; - assert.strictEqual(second, false); // Queued request returns false + // Second acquire fails immediately + const second = await mutex.acquire(); + assert.strictEqual(second, false); }); it('should unlock after release with no queue', async function () { @@ -293,30 +280,12 @@ describe('Persist Routes', function () { assert.strictEqual(mutex.locked, false); }); - it('should process multiple queued requests in order', async function () { + it('should allow new acquire after release', async function () { const mutex = new RestoreMutex(); - const results = []; - - // First acquire - const first = await mutex.acquire(); - results.push({ id: 1, acquired: first }); - - // Queue multiple requests - const p2 = mutex.acquire().then((r) => results.push({ id: 2, acquired: r })); - const p3 = mutex.acquire().then((r) => results.push({ id: 3, acquired: r })); - - // Release all - mutex.release(); // Signals #2 - mutex.release(); // Signals #3 - - await Promise.all([p2, p3]); - - assert.strictEqual(results[0].id, 1); - assert.strictEqual(results[0].acquired, true); - assert.strictEqual(results[1].id, 2); - assert.strictEqual(results[1].acquired, false); - assert.strictEqual(results[2].id, 3); - assert.strictEqual(results[2].acquired, false); + assert.strictEqual(await mutex.acquire(), true); + assert.strictEqual(await mutex.acquire(), false); + mutex.release(); + assert.strictEqual(await mutex.acquire(), true); }); }); diff --git a/tests/unit/web-server/route-helpers.test.ts b/tests/unit/web-server/route-helpers.test.ts new file mode 100644 index 00000000..4a494149 --- /dev/null +++ b/tests/unit/web-server/route-helpers.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { validateFilePath } from '../../../src/web-server/routes/route-helpers'; + +describe('validateFilePath', () => { + let tempDir: string; + let originalCcsHome: string | undefined; + let originalClaudeConfigDir: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-helpers-test-')); + originalCcsHome = process.env.CCS_HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + + process.env.CCS_HOME = tempDir; + process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-custom'); + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } + + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('allows files within ~/.ccs tree', () => { + const filePath = path.join(tempDir, '.ccs', 'config.yaml'); + const result = validateFilePath(filePath); + + expect(result.valid).toBe(true); + expect(result.readonly).toBe(false); + }); + + test('rejects sibling paths that only share ~/.ccs prefix', () => { + const bypassPath = path.join(tempDir, '.ccs-evil', 'config.yaml'); + const result = validateFilePath(bypassPath); + + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + }); + + test('allows readonly access to resolved Claude settings path', () => { + const filePath = path.join(tempDir, '.claude-custom', 'settings.json'); + const result = validateFilePath(filePath); + + expect(result.valid).toBe(true); + expect(result.readonly).toBe(true); + }); + + test('rejects symlinked paths inside ~/.ccs tree', () => { + if (process.platform === 'win32') { + return; + } + + const ccsDir = path.join(tempDir, '.ccs'); + const outsideDir = path.join(tempDir, 'outside'); + const linkedDir = path.join(ccsDir, 'linked'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.symlinkSync(outsideDir, linkedDir, 'dir'); + + const result = validateFilePath(path.join(linkedDir, 'config.yaml')); + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + }); + + test('rejects symlinked Claude settings path', () => { + if (process.platform === 'win32') { + return; + } + + const claudeDir = path.join(tempDir, '.claude-custom'); + const targetFile = path.join(tempDir, 'target-settings.json'); + const settingsPath = path.join(claudeDir, 'settings.json'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(targetFile, '{}'); + fs.symlinkSync(targetFile, settingsPath, 'file'); + + const result = validateFilePath(settingsPath); + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + }); +});