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); + }); +});