mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-05 18:16:28 +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 {
|
export function formatExportLine(shell: ShellType, key: string, value: string): string {
|
||||||
switch (shell) {
|
switch (shell) {
|
||||||
case 'fish':
|
case 'fish':
|
||||||
// Fish: single quotes prevent expansion; escape embedded single quotes with \'
|
// Fish: single quotes prevent expansion; escape embedded single quotes with '\''
|
||||||
return `set -gx ${key} '${value.replace(/'/g, "\\'")}'`;
|
return `set -gx ${key} '${value.replace(/'/g, "'\\''")}'`;
|
||||||
case 'powershell':
|
case 'powershell':
|
||||||
// PowerShell: single quotes prevent expansion; escape embedded single quotes with ''
|
// PowerShell: single quotes prevent expansion; escape embedded single quotes with ''
|
||||||
return `$env:${key} = '${value.replace(/'/g, "''")}'`;
|
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.
|
/** Map Anthropic env vars to OpenAI-compatible format.
|
||||||
* ANTHROPIC_MODEL is intentionally omitted — the proxy endpoint handles
|
* OPENAI_MODEL is included so tools that need it (e.g. OpenCode local provider)
|
||||||
* model routing, so OPENAI_MODEL is unnecessary for OpenAI-compatible tools. */
|
* can discover the model without additional configuration. */
|
||||||
export function transformToOpenAI(envVars: Record<string, string>): Record<string, string> {
|
export function transformToOpenAI(envVars: Record<string, string>): Record<string, string> {
|
||||||
const baseUrl = envVars['ANTHROPIC_BASE_URL'] || '';
|
const baseUrl = envVars['ANTHROPIC_BASE_URL'] || '';
|
||||||
const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || '';
|
const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || '';
|
||||||
return {
|
const model = envVars['ANTHROPIC_MODEL'] || '';
|
||||||
|
const result: Record<string, string> = {
|
||||||
OPENAI_API_KEY: apiKey,
|
OPENAI_API_KEY: apiKey,
|
||||||
OPENAI_BASE_URL: baseUrl,
|
OPENAI_BASE_URL: baseUrl,
|
||||||
LOCAL_ENDPOINT: baseUrl,
|
LOCAL_ENDPOINT: baseUrl,
|
||||||
};
|
};
|
||||||
|
if (model) result['OPENAI_MODEL'] = model;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse --key=value or --key value style args */
|
/** Parse --key=value or --key value style args */
|
||||||
@@ -73,6 +76,23 @@ export function parseFlag(args: string[], flag: string): string | undefined {
|
|||||||
return 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 */
|
/** Check if a profile is a CLIProxy profile */
|
||||||
function isCLIProxyProfile(name: string): boolean {
|
function isCLIProxyProfile(name: string): boolean {
|
||||||
return (CLIPROXY_PROFILES as readonly string[]).includes(name);
|
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)')}`
|
` ${color('--format', 'command')} <fmt> Output format: openai, anthropic, raw ${dim('(default: anthropic)')}`
|
||||||
);
|
);
|
||||||
console.log(
|
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(` ${color('--help, -h', 'command')} Show this help message`);
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -158,8 +178,9 @@ export async function handleEnvCommand(args: string[]): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse profile (first non-flag argument)
|
// Parse profile (first positional argument, skipping flag values)
|
||||||
const profile = args.find((a) => !a.startsWith('-'));
|
const flagsWithValues = ['format', 'shell'];
|
||||||
|
const profile = findProfile(args, flagsWithValues);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
console.error(fail('Usage: ccs env <profile> [--format openai|anthropic|raw]'));
|
console.error(fail('Usage: ccs env <profile> [--format openai|anthropic|raw]'));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -235,7 +256,11 @@ export async function handleEnvCommand(args: string[]): Promise<void> {
|
|||||||
|
|
||||||
// Output shell-formatted exports to stdout
|
// Output shell-formatted exports to stdout
|
||||||
for (const [key, value] of Object.entries(output)) {
|
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));
|
console.log(formatExportLine(shell, key, value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
formatExportLine,
|
formatExportLine,
|
||||||
transformToOpenAI,
|
transformToOpenAI,
|
||||||
parseFlag,
|
parseFlag,
|
||||||
|
findProfile,
|
||||||
} from '../../../src/commands/env-command';
|
} from '../../../src/commands/env-command';
|
||||||
|
|
||||||
describe('env-command', () => {
|
describe('env-command', () => {
|
||||||
@@ -98,7 +99,7 @@ describe('env-command', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('escapes single quotes in fish values', () => {
|
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', () => {
|
it('escapes single quotes in powershell values', () => {
|
||||||
@@ -120,6 +121,7 @@ describe('env-command', () => {
|
|||||||
OPENAI_API_KEY: 'ccs-internal-managed',
|
OPENAI_API_KEY: 'ccs-internal-managed',
|
||||||
OPENAI_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini',
|
OPENAI_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini',
|
||||||
LOCAL_ENDPOINT: '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',
|
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(Object.keys(result)).toHaveLength(3);
|
||||||
expect(result['ANTHROPIC_MAX_TOKENS']).toBeUndefined();
|
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', () => {
|
describe('parseFlag', () => {
|
||||||
@@ -168,4 +179,30 @@ describe('env-command', () => {
|
|||||||
expect(parseFlag(['--format', '--shell'], 'format')).toBeUndefined();
|
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