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;