mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user