diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index 7019e0e1..92bf626f 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -36,8 +36,8 @@ export function detectShell(flag?: string): ShellType { export function formatExportLine(shell: ShellType, key: string, value: string): string { switch (shell) { case 'fish': - // Fish: single quotes prevent expansion; escape embedded single quotes with \' - return `set -gx ${key} '${value.replace(/'/g, "\\'")}'`; + // Fish: single quotes prevent expansion; escape embedded single quotes with '\'' + return `set -gx ${key} '${value.replace(/'/g, "'\\''")}'`; case 'powershell': // PowerShell: single quotes prevent expansion; escape embedded single quotes with '' return `$env:${key} = '${value.replace(/'/g, "''")}'`; @@ -48,16 +48,19 @@ export function formatExportLine(shell: ShellType, key: string, value: string): } /** Map Anthropic env vars to OpenAI-compatible format. - * ANTHROPIC_MODEL is intentionally omitted — the proxy endpoint handles - * model routing, so OPENAI_MODEL is unnecessary for OpenAI-compatible tools. */ + * OPENAI_MODEL is included so tools that need it (e.g. OpenCode local provider) + * can discover the model without additional configuration. */ export function transformToOpenAI(envVars: Record): Record { const baseUrl = envVars['ANTHROPIC_BASE_URL'] || ''; const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || ''; - return { + const model = envVars['ANTHROPIC_MODEL'] || ''; + const result: Record = { OPENAI_API_KEY: apiKey, OPENAI_BASE_URL: baseUrl, LOCAL_ENDPOINT: baseUrl, }; + if (model) result['OPENAI_MODEL'] = model; + return result; } /** Parse --key=value or --key value style args */ @@ -73,6 +76,23 @@ export function parseFlag(args: string[], flag: string): string | undefined { return undefined; } +/** Find the first positional argument, skipping flags and their values */ +export function findProfile(args: string[], flagsWithValues: string[]): string | undefined { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('-')) { + // Skip flag values: --flag=value (single token) or --flag value (two tokens) + const flagName = arg.replace(/^--/, '').split('=')[0]; + if (!arg.includes('=') && flagsWithValues.includes(flagName) && i + 1 < args.length) { + i++; // skip next arg (the value) + } + continue; + } + return arg; + } + return undefined; +} + /** Check if a profile is a CLIProxy profile */ function isCLIProxyProfile(name: string): boolean { return (CLIPROXY_PROFILES as readonly string[]).includes(name); @@ -115,7 +135,7 @@ function showHelp(): void { ` ${color('--format', 'command')} Output format: openai, anthropic, raw ${dim('(default: anthropic)')}` ); console.log( - ` ${color('--shell', 'command')} Shell syntax: auto, bash, fish, powershell ${dim('(default: auto)')}` + ` ${color('--shell', 'command')} Shell syntax: auto, bash/zsh, fish, powershell ${dim('(default: auto)')}` ); console.log(` ${color('--help, -h', 'command')} Show this help message`); console.log(''); @@ -158,8 +178,9 @@ export async function handleEnvCommand(args: string[]): Promise { return; } - // Parse profile (first non-flag argument) - const profile = args.find((a) => !a.startsWith('-')); + // Parse profile (first positional argument, skipping flag values) + const flagsWithValues = ['format', 'shell']; + const profile = findProfile(args, flagsWithValues); if (!profile) { console.error(fail('Usage: ccs env [--format openai|anthropic|raw]')); process.exit(1); @@ -235,7 +256,11 @@ export async function handleEnvCommand(args: string[]): Promise { // Output shell-formatted exports to stdout for (const [key, value] of Object.entries(output)) { - if (value && VALID_ENV_KEY.test(key)) { + if (!VALID_ENV_KEY.test(key)) { + console.error(dim(` Skipping invalid key: ${key}`)); + continue; + } + if (value) { console.log(formatExportLine(shell, key, value)); } } diff --git a/tests/unit/commands/env-command.test.ts b/tests/unit/commands/env-command.test.ts index 1bf01e5c..d1c0aeca 100644 --- a/tests/unit/commands/env-command.test.ts +++ b/tests/unit/commands/env-command.test.ts @@ -9,6 +9,7 @@ import { formatExportLine, transformToOpenAI, parseFlag, + findProfile, } from '../../../src/commands/env-command'; describe('env-command', () => { @@ -98,7 +99,7 @@ describe('env-command', () => { }); it('escapes single quotes in fish values', () => { - expect(formatExportLine('fish', 'VAL', "it's here")).toBe("set -gx VAL 'it\\'s here'"); + expect(formatExportLine('fish', 'VAL', "it's here")).toBe("set -gx VAL 'it'\\''s here'"); }); it('escapes single quotes in powershell values', () => { @@ -120,6 +121,7 @@ describe('env-command', () => { OPENAI_API_KEY: 'ccs-internal-managed', OPENAI_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini', LOCAL_ENDPOINT: 'http://127.0.0.1:8317/api/provider/gemini', + OPENAI_MODEL: 'gemini-claude-sonnet-4-5', }); }); @@ -141,10 +143,19 @@ describe('env-command', () => { DISABLE_TELEMETRY: '1', }); - // Should only have 3 keys + // Should only have 3 keys (no OPENAI_MODEL when ANTHROPIC_MODEL absent) expect(Object.keys(result)).toHaveLength(3); expect(result['ANTHROPIC_MAX_TOKENS']).toBeUndefined(); }); + + it('omits OPENAI_MODEL when ANTHROPIC_MODEL absent', () => { + const result = transformToOpenAI({ + ANTHROPIC_BASE_URL: 'http://localhost:8317', + ANTHROPIC_AUTH_TOKEN: 'key', + }); + + expect(result['OPENAI_MODEL']).toBeUndefined(); + }); }); describe('parseFlag', () => { @@ -168,4 +179,30 @@ describe('env-command', () => { expect(parseFlag(['--format', '--shell'], 'format')).toBeUndefined(); }); }); + + describe('findProfile', () => { + it('finds profile as first positional arg', () => { + expect(findProfile(['gemini'], ['format', 'shell'])).toBe('gemini'); + }); + + it('skips flags before profile', () => { + expect(findProfile(['--format', 'openai', 'gemini'], ['format', 'shell'])).toBe('gemini'); + }); + + it('skips --flag=value style flags', () => { + expect(findProfile(['--format=openai', 'gemini'], ['format', 'shell'])).toBe('gemini'); + }); + + it('handles profile before flags', () => { + expect(findProfile(['gemini', '--format', 'openai'], ['format', 'shell'])).toBe('gemini'); + }); + + it('returns undefined when no positional args', () => { + expect(findProfile(['--format', 'openai', '--shell', 'fish'], ['format', 'shell'])).toBeUndefined(); + }); + + it('skips multiple flag-value pairs', () => { + expect(findProfile(['--format', 'openai', '--shell', 'fish', 'codex'], ['format', 'shell'])).toBe('codex'); + }); + }); });