fix(env): fix fish escaping, profile parsing, and add OPENAI_MODEL mapping

- Fix P0: fish single-quote escaping uses '\'' (end-quote, literal, reopen)
  instead of \' which fish doesn't support inside single-quoted strings
- Fix P0: profile arg parsing now skips flag values via findProfile() so
  `ccs env --format openai gemini` correctly resolves to 'gemini'
- Add OPENAI_MODEL mapping from ANTHROPIC_MODEL in transformToOpenAI
- Add stderr warning when invalid env var keys are silently dropped
- Update --shell help text to mention zsh compatibility
- Add findProfile tests (6) and OPENAI_MODEL omission test
This commit is contained in:
Tam Nhu Tran
2026-02-11 06:52:21 +07:00
parent 44b3152d34
commit d5c03d1f2d
2 changed files with 73 additions and 11 deletions
+34 -9
View File
@@ -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<string, string>): Record<string, string> {
const baseUrl = envVars['ANTHROPIC_BASE_URL'] || '';
const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || '';
return {
const model = envVars['ANTHROPIC_MODEL'] || '';
const result: Record<string, string> = {
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')} <fmt> Output format: openai, anthropic, raw ${dim('(default: anthropic)')}`
);
console.log(
` ${color('--shell', 'command')} <sh> Shell syntax: auto, bash, fish, powershell ${dim('(default: auto)')}`
` ${color('--shell', 'command')} <sh> 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<void> {
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 <profile> [--format openai|anthropic|raw]'));
process.exit(1);
@@ -235,7 +256,11 @@ export async function handleEnvCommand(args: string[]): Promise<void> {
// 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));
}
}
+39 -2
View File
@@ -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');
});
});
});