From 2e85064b8a6a3eed694abb56bef70bf889f648d3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 02:38:29 +0700 Subject: [PATCH 01/11] feat(env): add ccs env command for third-party tool integration New `ccs env ` command exports shell-evaluable environment variables for OpenCode, Cursor, Continue, and other third-party tools. Supports --format (openai|anthropic|raw) and --shell (auto|bash|fish| powershell) flags. Auto-detects shell from $SHELL env var. Closes #503 --- CLAUDE.md | 1 + src/ccs.ts | 7 + src/commands/env-command.ts | 229 ++++++++++++++++++++++++ src/commands/help-command.ts | 9 + tests/unit/commands/env-command.test.ts | 129 +++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 src/commands/env-command.ts create mode 100644 tests/unit/commands/env-command.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f8a9d07b..a8aaa564 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,7 @@ bun run validate # Step 3: Final check (must pass) | `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` | | `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` | | `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` | +| `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` | | `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` | | `ccs setup --help` | `src/commands/setup-command.ts` → `showHelp()` | diff --git a/src/ccs.ts b/src/ccs.ts index 5150ff00..9e302de7 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -450,6 +450,13 @@ async function main(): Promise { return; } + // Special case: env command (export env vars for third-party tools) + if (firstArg === 'env') { + const { handleEnvCommand } = await import('./commands/env-command'); + await handleEnvCommand(args.slice(1)); + return; + } + // Special case: setup command (first-time wizard) if (firstArg === 'setup' || firstArg === '--setup') { const { handleSetupCommand } = await import('./commands/setup-command'); diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts new file mode 100644 index 00000000..fac4f2fb --- /dev/null +++ b/src/commands/env-command.ts @@ -0,0 +1,229 @@ +/** + * Env Command Handler + * + * Export environment variables for third-party tool integration. + * Outputs shell-evaluable exports for OpenCode, Cursor, Continue, etc. + */ + +import { initUI, header, dim, color, subheader, fail, warn } from '../utils/ui'; +import { CLIProxyProvider } from '../cliproxy/types'; +import { CLIPROXY_PROFILES, loadSettingsFromFile } from '../auth/profile-detector'; +import { getEffectiveEnvVars } from '../cliproxy/config/env-builder'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; +import { isUnifiedMode, loadUnifiedConfig } from '../config/unified-config-loader'; +import { expandPath } from '../utils/helpers'; + +type ShellType = 'bash' | 'fish' | 'powershell'; +type OutputFormat = 'openai' | 'anthropic' | 'raw'; + +const VALID_FORMATS: OutputFormat[] = ['openai', 'anthropic', 'raw']; +const VALID_SHELLS: ShellType[] = ['bash', 'fish', 'powershell']; + +/** Auto-detect shell from environment */ +export function detectShell(flag?: string): ShellType { + if (flag && flag !== 'auto' && VALID_SHELLS.includes(flag as ShellType)) { + return flag as ShellType; + } + const shell = process.env['SHELL'] || ''; + if (shell.includes('fish')) return 'fish'; + if (process.platform === 'win32') return 'powershell'; + return 'bash'; +} + +/** Format a single env var export for the target shell */ +export function formatExportLine(shell: ShellType, key: string, value: string): string { + // Escape double quotes in value + const escaped = value.replace(/"/g, '\\"'); + switch (shell) { + case 'fish': + return `set -gx ${key} "${escaped}"`; + case 'powershell': + return `$env:${key} = "${escaped}"`; + default: + return `export ${key}="${escaped}"`; + } +} + +/** Map Anthropic env vars to OpenAI-compatible format */ +export function transformToOpenAI(envVars: Record): Record { + const baseUrl = envVars['ANTHROPIC_BASE_URL'] || ''; + const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || ''; + return { + OPENAI_API_KEY: apiKey, + OPENAI_BASE_URL: baseUrl, + LOCAL_ENDPOINT: baseUrl, + }; +} + +/** Parse --key=value or --key value style args */ +function parseFlag(args: string[], flag: string): string | undefined { + // --flag=value style + const eqMatch = args.find((a) => a.startsWith(`--${flag}=`)); + if (eqMatch) return eqMatch.split('=')[1]; + // --flag value style + const idx = args.indexOf(`--${flag}`); + if (idx >= 0 && idx + 1 < args.length && !args[idx + 1].startsWith('-')) { + return args[idx + 1]; + } + return undefined; +} + +/** Check if a profile is a CLIProxy profile */ +function isCLIProxyProfile(name: string): boolean { + return (CLIPROXY_PROFILES as readonly string[]).includes(name); +} + +/** Resolve env vars for settings-based profiles (glm, kimi, custom API profiles) */ +function resolveSettingsProfile(profileName: string): Record | null { + if (!isUnifiedMode()) return null; + + const config = loadUnifiedConfig(); + if (!config) return null; + + // Check unified config profiles section + const profileConfig = config.profiles?.[profileName]; + if (!profileConfig) return null; + + if (profileConfig.type === 'api' && profileConfig.settings) { + const settingsPath = expandPath(profileConfig.settings); + const env = loadSettingsFromFile(settingsPath); + if (Object.keys(env).length > 0) return env; + } + + return null; +} + +/** Show help for env command */ +function showHelp(): void { + console.log(''); + console.log(header('ccs env')); + console.log(''); + console.log(' Export environment variables for third-party tool integration.'); + console.log(''); + + console.log(subheader('Usage:')); + console.log(` ${color('ccs env', 'command')} [options]`); + console.log(''); + + console.log(subheader('Options:')); + console.log( + ` ${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)')}` + ); + console.log(` ${color('--help, -h', 'command')} Show this help message`); + console.log(''); + + console.log(subheader('Formats:')); + console.log( + ` ${color('openai', 'command')} OPENAI_API_KEY, OPENAI_BASE_URL, LOCAL_ENDPOINT` + ); + console.log( + ` ${color('anthropic', 'command')} ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL` + ); + console.log(` ${color('raw', 'command')} All effective env vars as-is`); + console.log(''); + + console.log(subheader('Examples:')); + console.log( + ` $ ${color('eval $(ccs env gemini --format openai)', 'command')} ${dim('# For OpenCode/Cursor')}` + ); + console.log( + ` $ ${color('ccs env codex --format anthropic', 'command')} ${dim('# Anthropic vars')}` + ); + console.log( + ` $ ${color('ccs env glm --format raw', 'command')} ${dim('# All vars from settings')}` + ); + console.log( + ` $ ${color('ccs env agy --format openai --shell fish', 'command')} ${dim('# Fish shell syntax')}` + ); + console.log(''); +} + +/** + * Handle env command + * @param args - Command line arguments (after 'env') + */ +export async function handleEnvCommand(args: string[]): Promise { + await initUI(); + + if (args.includes('--help') || args.includes('-h')) { + showHelp(); + return; + } + + // Parse profile (first non-flag argument) + const profile = args.find((a) => !a.startsWith('-')); + if (!profile) { + console.error(fail('Usage: ccs env [--format openai|anthropic|raw]')); + process.exit(1); + } + + // Parse flags + const formatStr = parseFlag(args, 'format') || 'anthropic'; + if (!VALID_FORMATS.includes(formatStr as OutputFormat)) { + console.error(fail(`Invalid format: ${formatStr}. Use: ${VALID_FORMATS.join(', ')}`)); + process.exit(1); + } + const format = formatStr as OutputFormat; + + const shellStr = parseFlag(args, 'shell') || 'auto'; + const shell = detectShell(shellStr); + + // Resolve env vars based on profile type + let envVars: Record = {}; + + if (isCLIProxyProfile(profile)) { + // CLIProxy profile (gemini, codex, agy, etc.) + const provider = profile as CLIProxyProvider; + const resolved = getEffectiveEnvVars(provider, CLIPROXY_DEFAULT_PORT); + // Convert NodeJS.ProcessEnv to Record + for (const [k, v] of Object.entries(resolved)) { + if (v !== undefined) envVars[k] = v; + } + } else { + // Settings-based profile (glm, kimi, custom API) + const resolved = resolveSettingsProfile(profile); + if (!resolved) { + console.error(fail(`Profile '${profile}' not found.`)); + console.error(dim(' Available CLIProxy profiles: ' + CLIPROXY_PROFILES.join(', '))); + console.error(dim(' Check ~/.ccs/config.yaml for custom profiles.')); + process.exit(1); + } + envVars = resolved; + } + + if (Object.keys(envVars).length === 0) { + console.error(warn(`No env vars resolved for profile '${profile}'.`)); + process.exit(1); + } + + // Transform to requested format + let output: Record; + switch (format) { + case 'openai': + output = transformToOpenAI(envVars); + break; + case 'anthropic': { + // Filter to only Anthropic-relevant vars + output = {}; + for (const [k, v] of Object.entries(envVars)) { + if (k.startsWith('ANTHROPIC_')) { + output[k] = v; + } + } + break; + } + case 'raw': + output = envVars; + break; + } + + // Output shell-formatted exports to stdout + for (const [key, value] of Object.entries(output)) { + if (value) { + console.log(formatExportLine(shell, key, value)); + } + } +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 2097453f..66938823 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -257,6 +257,15 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs update --beta', 'Install from dev channel (unstable)'], ]); + // Environment export + printSubSection('Environment Export', [ + ['ccs env ', 'Export env vars for third-party tools'], + ['ccs env --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'], + ['ccs env --format anthropic', 'Anthropic vars (default)'], + ['ccs env --format raw', 'All effective env vars'], + ['ccs env --shell fish', 'Fish shell syntax'], + ]); + // Flags printSubSection('Flags', [ ['-h, --help', 'Show this help message'], diff --git a/tests/unit/commands/env-command.test.ts b/tests/unit/commands/env-command.test.ts new file mode 100644 index 00000000..898714e9 --- /dev/null +++ b/tests/unit/commands/env-command.test.ts @@ -0,0 +1,129 @@ +/** + * Unit tests for env-command.ts + * + * Tests pure utility functions: detectShell, formatExportLine, transformToOpenAI + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { detectShell, formatExportLine, transformToOpenAI } from '../../../src/commands/env-command'; + +describe('env-command', () => { + describe('detectShell', () => { + const originalShell = process.env['SHELL']; + const originalPlatform = process.platform; + + afterEach(() => { + if (originalShell !== undefined) { + process.env['SHELL'] = originalShell; + } else { + delete process.env['SHELL']; + } + }); + + it('returns explicit bash flag', () => { + expect(detectShell('bash')).toBe('bash'); + }); + + it('returns explicit fish flag', () => { + expect(detectShell('fish')).toBe('fish'); + }); + + it('returns explicit powershell flag', () => { + expect(detectShell('powershell')).toBe('powershell'); + }); + + it('auto-detects bash from SHELL=/bin/zsh', () => { + process.env['SHELL'] = '/bin/zsh'; + expect(detectShell('auto')).toBe('bash'); + }); + + it('auto-detects bash from SHELL=/bin/bash', () => { + process.env['SHELL'] = '/bin/bash'; + expect(detectShell()).toBe('bash'); + }); + + it('auto-detects fish from SHELL=/usr/bin/fish', () => { + process.env['SHELL'] = '/usr/bin/fish'; + expect(detectShell('auto')).toBe('fish'); + }); + + it('defaults to bash when SHELL is empty', () => { + process.env['SHELL'] = ''; + expect(detectShell()).toBe('bash'); + }); + + it('ignores invalid flag and auto-detects', () => { + process.env['SHELL'] = '/bin/bash'; + expect(detectShell('invalid')).toBe('bash'); + }); + }); + + describe('formatExportLine', () => { + it('formats bash export', () => { + expect(formatExportLine('bash', 'API_KEY', 'sk-123')).toBe('export API_KEY="sk-123"'); + }); + + it('formats fish export', () => { + expect(formatExportLine('fish', 'API_KEY', 'sk-123')).toBe('set -gx API_KEY "sk-123"'); + }); + + it('formats powershell export', () => { + expect(formatExportLine('powershell', 'API_KEY', 'sk-123')).toBe( + '$env:API_KEY = "sk-123"' + ); + }); + + it('escapes double quotes in values', () => { + expect(formatExportLine('bash', 'VAL', 'has "quotes"')).toBe( + 'export VAL="has \\"quotes\\""' + ); + }); + + it('handles empty values', () => { + expect(formatExportLine('bash', 'EMPTY', '')).toBe('export EMPTY=""'); + }); + + it('handles URLs with special characters', () => { + const url = 'http://127.0.0.1:8317/api/provider/gemini'; + expect(formatExportLine('bash', 'BASE_URL', url)).toBe(`export BASE_URL="${url}"`); + }); + }); + + describe('transformToOpenAI', () => { + it('maps Anthropic vars to OpenAI format', () => { + const result = transformToOpenAI({ + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gemini-claude-sonnet-4-5', + }); + + expect(result).toEqual({ + 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', + }); + }); + + it('handles missing source vars gracefully', () => { + const result = transformToOpenAI({}); + + expect(result).toEqual({ + OPENAI_API_KEY: '', + OPENAI_BASE_URL: '', + LOCAL_ENDPOINT: '', + }); + }); + + it('only extracts relevant vars', () => { + const result = transformToOpenAI({ + ANTHROPIC_BASE_URL: 'http://localhost:8317', + ANTHROPIC_AUTH_TOKEN: 'key', + ANTHROPIC_MAX_TOKENS: '8096', + DISABLE_TELEMETRY: '1', + }); + + // Should only have 3 keys + expect(Object.keys(result)).toHaveLength(3); + expect(result['ANTHROPIC_MAX_TOKENS']).toBeUndefined(); + }); + }); +}); From a5dc15d174dec2e39b251d63bc4a4093003fc0cb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 06:26:52 +0700 Subject: [PATCH 02/11] fix(env): use single quotes to prevent shell injection via eval Switch formatExportLine from double quotes to single quotes to prevent shell metacharacter expansion ($(), backticks, etc.) when output is consumed via eval. Also fix parseFlag to handle values containing =, remove unused test imports, and add empty-output guard after format transformation. --- src/commands/env-command.ts | 23 ++++++++++++++++------- tests/unit/commands/env-command.test.ts | 25 +++++++++++++++---------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index fac4f2fb..a29754fa 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -30,17 +30,18 @@ export function detectShell(flag?: string): ShellType { return 'bash'; } -/** Format a single env var export for the target shell */ +/** Format a single env var export for the target shell (single-quoted to prevent injection) */ export function formatExportLine(shell: ShellType, key: string, value: string): string { - // Escape double quotes in value - const escaped = value.replace(/"/g, '\\"'); switch (shell) { case 'fish': - return `set -gx ${key} "${escaped}"`; + // Fish: single quotes prevent expansion; escape embedded single quotes with \' + return `set -gx ${key} '${value.replace(/'/g, "\\'")}'`; case 'powershell': - return `$env:${key} = "${escaped}"`; + // PowerShell: single quotes prevent expansion; escape embedded single quotes with '' + return `$env:${key} = '${value.replace(/'/g, "''")}'`; default: - return `export ${key}="${escaped}"`; + // Bash/zsh: single quotes prevent all expansion; handle embedded single quotes + return `export ${key}='${value.replace(/'/g, "'\\''")}'`; } } @@ -59,7 +60,7 @@ export function transformToOpenAI(envVars: Record): Record a.startsWith(`--${flag}=`)); - if (eqMatch) return eqMatch.split('=')[1]; + if (eqMatch) return eqMatch.split('=').slice(1).join('='); // --flag value style const idx = args.indexOf(`--${flag}`); if (idx >= 0 && idx + 1 < args.length && !args[idx + 1].startsWith('-')) { @@ -220,6 +221,14 @@ export async function handleEnvCommand(args: string[]): Promise { break; } + // Guard: format transformation may filter out all vars + if (Object.keys(output).filter((k) => output[k]).length === 0) { + console.error( + warn(`No ${format}-format vars found for profile '${profile}'. Try --format raw`) + ); + process.exit(1); + } + // Output shell-formatted exports to stdout for (const [key, value] of Object.entries(output)) { if (value) { diff --git a/tests/unit/commands/env-command.test.ts b/tests/unit/commands/env-command.test.ts index 898714e9..efcae254 100644 --- a/tests/unit/commands/env-command.test.ts +++ b/tests/unit/commands/env-command.test.ts @@ -3,13 +3,12 @@ * * Tests pure utility functions: detectShell, formatExportLine, transformToOpenAI */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, it, expect, afterEach } from 'bun:test'; import { detectShell, formatExportLine, transformToOpenAI } from '../../../src/commands/env-command'; describe('env-command', () => { describe('detectShell', () => { const originalShell = process.env['SHELL']; - const originalPlatform = process.platform; afterEach(() => { if (originalShell !== undefined) { @@ -59,32 +58,38 @@ describe('env-command', () => { describe('formatExportLine', () => { it('formats bash export', () => { - expect(formatExportLine('bash', 'API_KEY', 'sk-123')).toBe('export API_KEY="sk-123"'); + expect(formatExportLine('bash', 'API_KEY', 'sk-123')).toBe("export API_KEY='sk-123'"); }); it('formats fish export', () => { - expect(formatExportLine('fish', 'API_KEY', 'sk-123')).toBe('set -gx API_KEY "sk-123"'); + expect(formatExportLine('fish', 'API_KEY', 'sk-123')).toBe("set -gx API_KEY 'sk-123'"); }); it('formats powershell export', () => { expect(formatExportLine('powershell', 'API_KEY', 'sk-123')).toBe( - '$env:API_KEY = "sk-123"' + "$env:API_KEY = 'sk-123'" ); }); - it('escapes double quotes in values', () => { - expect(formatExportLine('bash', 'VAL', 'has "quotes"')).toBe( - 'export VAL="has \\"quotes\\""' + it('escapes single quotes in values', () => { + expect(formatExportLine('bash', 'VAL', "it's here")).toBe( + "export VAL='it'\\''s here'" ); }); it('handles empty values', () => { - expect(formatExportLine('bash', 'EMPTY', '')).toBe('export EMPTY=""'); + expect(formatExportLine('bash', 'EMPTY', '')).toBe("export EMPTY=''"); }); it('handles URLs with special characters', () => { const url = 'http://127.0.0.1:8317/api/provider/gemini'; - expect(formatExportLine('bash', 'BASE_URL', url)).toBe(`export BASE_URL="${url}"`); + expect(formatExportLine('bash', 'BASE_URL', url)).toBe(`export BASE_URL='${url}'`); + }); + + it('prevents shell injection with $() in values', () => { + expect(formatExportLine('bash', 'TOKEN', 'safe$(whoami)')).toBe( + "export TOKEN='safe$(whoami)'" + ); }); }); From 76457a567d7d1fe52a983a3c6e37b49d503071c9 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 06:36:51 +0700 Subject: [PATCH 03/11] fix(env): add key sanitization and shell completions - Validate env var keys match ^[A-Za-z_][A-Za-z0-9_]*$ before output to prevent injection via crafted config files - Add env command to all 4 shell completion scripts (bash, zsh, fish, PowerShell) with sub-completions for --format and --shell flags --- scripts/completion/ccs.bash | 29 +++++++++++++++++++- scripts/completion/ccs.fish | 42 +++++++++++++++++------------ scripts/completion/ccs.ps1 | 54 ++++++++++++++++++++++++++++++++++++- scripts/completion/ccs.zsh | 10 ++++++- src/commands/env-command.ts | 3 ++- 5 files changed, 117 insertions(+), 21 deletions(-) diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index d0d13d33..a9b1d392 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -18,7 +18,7 @@ _ccs_completion() { # Top-level completion (first argument) if [[ ${COMP_CWORD} -eq 1 ]]; then - local commands="auth api cliproxy doctor sync update" + local commands="auth api cliproxy doctor env sync update" local flags="--help --version --shell-completion -h -v -sc" local cliproxy_profiles="gemini codex agy qwen" local profiles="" @@ -151,6 +151,33 @@ _ccs_completion() { esac fi + # env subcommands + if [[ ${COMP_WORDS[1]} == "env" ]]; then + case "${prev}" in + env) + # Complete with profile names and flags + local env_opts="--format --shell --help -h $cliproxy_profiles" + if [[ -f ~/.ccs/config.json ]]; then + env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)" + fi + COMPREPLY=( $(compgen -W "${env_opts}" -- ${cur}) ) + return 0 + ;; + --format) + COMPREPLY=( $(compgen -W "openai anthropic raw" -- ${cur}) ) + return 0 + ;; + --shell) + COMPREPLY=( $(compgen -W "bash fish powershell" -- ${cur}) ) + return 0 + ;; + *) + COMPREPLY=( $(compgen -W "--format --shell --help -h" -- ${cur}) ) + return 0 + ;; + esac + fi + # Flags for doctor command if [[ ${COMP_WORDS[1]} == "doctor" ]]; then COMPREPLY=( $(compgen -W "--help -h" -- ${cur}) ) diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index cb058cc8..9593e736 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -121,33 +121,34 @@ complete -c ccs -s v -l version -d 'Show version information' complete -c ccs -s sc -l shell-completion -d 'Install shell completion' # Commands - grouped with [cmd] prefix for visual distinction -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'auth' -d '[cmd] Manage multiple Claude accounts' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'api' -d '[cmd] Manage API profiles (create/remove)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'cliproxy' -d '[cmd] Manage CLIProxy variants and binary' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'doctor' -d '[cmd] Run health check and diagnostics' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'sync' -d '[cmd] Sync delegation commands and skills' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'update' -d '[cmd] Update CCS to latest version' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'auth' -d '[cmd] Manage multiple Claude accounts' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'api' -d '[cmd] Manage API profiles (create/remove)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'cliproxy' -d '[cmd] Manage CLIProxy variants and binary' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'doctor' -d '[cmd] Run health check and diagnostics' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'env' -d '[cmd] Export env vars for third-party tools' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'sync' -d '[cmd] Sync delegation commands and skills' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'update' -d '[cmd] Update CCS to latest version' # CLIProxy profiles - grouped with [proxy] prefix for OAuth providers -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'gemini' -d '[proxy] Google Gemini (OAuth)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'codex' -d '[proxy] OpenAI Codex (OAuth)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'agy' -d '[proxy] Antigravity (OAuth)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'qwen' -d '[proxy] Qwen Code (OAuth)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'gemini' -d '[proxy] Google Gemini (OAuth)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'codex' -d '[proxy] OpenAI Codex (OAuth)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'agy' -d '[proxy] Antigravity (OAuth)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'qwen' -d '[proxy] Qwen Code (OAuth)' # Model profiles - grouped with [model] prefix for visual distinction -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'default' -d '[model] Default Claude Sonnet 4.5' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'glm' -d '[model] GLM-4.6 (cost-optimized)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'glmt' -d '[model] GLM-4.6 with thinking mode' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'kimi' -d '[model] Kimi for Coding (long-context)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'default' -d '[model] Default Claude Sonnet 4.5' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'glm' -d '[model] GLM-4.6 (cost-optimized)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'glmt' -d '[model] GLM-4.6 with thinking mode' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'kimi' -d '[model] Kimi for Coding (long-context)' # Custom model profiles - dynamic with [model] prefix -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a '(__fish_ccs_get_custom_settings_profiles)' -d '[model] Settings-based profile' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a '(__fish_ccs_get_custom_settings_profiles)' -d '[model] Settings-based profile' # CLIProxy variants - dynamic with [variant] prefix -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a '(__fish_ccs_get_cliproxy_variants)' -d '[variant] CLIProxy variant' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a '(__fish_ccs_get_cliproxy_variants)' -d '[variant] CLIProxy variant' # Account profiles - dynamic with [account] prefix -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a '(__fish_ccs_get_account_profiles)' -d '[account] Account-based profile' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a '(__fish_ccs_get_account_profiles)' -d '[account] Account-based profile' # shell-completion subflags complete -c ccs -n '__fish_seen_argument -l shell-completion; or __fish_seen_argument -s sc' -l bash -d 'Install for bash' @@ -171,6 +172,13 @@ complete -c ccs -n '__fish_seen_subcommand_from update' -s h -l help -d 'Show he # doctor command flags complete -c ccs -n '__fish_seen_subcommand_from doctor' -s h -l help -d 'Show help for doctor command' +# env command completions +complete -c ccs -n '__fish_seen_subcommand_from env' -l format -d 'Output format' +complete -c ccs -n '__fish_seen_subcommand_from env; and __fish_seen_argument -l format' -a 'openai anthropic raw' -d 'Format' +complete -c ccs -n '__fish_seen_subcommand_from env' -l shell -d 'Shell syntax' +complete -c ccs -n '__fish_seen_subcommand_from env; and __fish_seen_argument -l shell' -a 'bash fish powershell' -d 'Shell' +complete -c ccs -n '__fish_seen_subcommand_from env' -s h -l help -d 'Show help for env command' + # ============================================================================ # auth subcommands # ============================================================================ diff --git a/scripts/completion/ccs.ps1 b/scripts/completion/ccs.ps1 index eb640cbf..9f5ac508 100644 --- a/scripts/completion/ccs.ps1 +++ b/scripts/completion/ccs.ps1 @@ -12,7 +12,7 @@ Register-ArgumentCompleter -CommandName ccs -ScriptBlock { param($commandName, $wordToComplete, $commandAst, $fakeBoundParameters) - $commands = @('auth', 'api', 'cliproxy', 'doctor', 'sync', 'update', '--help', '--version', '--shell-completion', '-h', '-v', '-sc') + $commands = @('auth', 'api', 'cliproxy', 'doctor', 'env', 'sync', 'update', '--help', '--version', '--shell-completion', '-h', '-v', '-sc') $cliproxyProfiles = @('gemini', 'codex', 'agy', 'qwen') $authCommands = @('create', 'list', 'show', 'remove', 'default', '--help', '-h') $apiCommands = @('create', 'list', 'remove', '--help', '-h') @@ -21,6 +21,9 @@ Register-ArgumentCompleter -CommandName ccs -ScriptBlock { $cliproxyCreateFlags = @('--provider', '--model', '--force', '--yes', '-y') $providerFlags = @('--auth', '--config', '--logout', '--headless', '--help', '-h') $updateFlags = @('--force', '--beta', '--dev', '--help', '-h') + $envFlags = @('--format', '--shell', '--help', '-h') + $envFormats = @('openai', 'anthropic', 'raw') + $envShells = @('bash', 'fish', 'powershell') $shellCompletionFlags = @('--bash', '--zsh', '--fish', '--powershell') $listFlags = @('--verbose', '--json') $removeFlags = @('--yes', '-y') @@ -130,6 +133,55 @@ Register-ArgumentCompleter -CommandName ccs -ScriptBlock { return } + # env command completion + if ($words[1] -eq 'env') { + if ($position -eq 3) { + $options = $cliproxyProfiles + (Get-CcsProfiles -Type settings) + $envFlags + $options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } elseif ($position -ge 4) { + switch ($words[$position - 2]) { + '--format' { + $envFormats | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } + '--shell' { + $envShells | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } + default { + $envFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } + } + } + return + } + # auth subcommand completion if ($words[1] -eq 'auth') { if ($position -eq 3) { diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index cb0ef587..0ab058f4 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -13,7 +13,7 @@ # sudo cp scripts/completion/ccs.zsh /usr/local/share/zsh/site-functions/_ccs # Set up completion styles for better formatting and colors -zstyle ':completion:*:*:ccs:*:commands' list-colors '=(#b)(auth|api|cliproxy|doctor|sync|update)([[:space:]]#--[[:space:]]#*)==0\;34=2\;37' +zstyle ':completion:*:*:ccs:*:commands' list-colors '=(#b)(auth|api|cliproxy|doctor|env|sync|update)([[:space:]]#--[[:space:]]#*)==0\;34=2\;37' zstyle ':completion:*:*:ccs:*:proxy-profiles' list-colors '=(#b)(gemini|codex|agy|qwen)([[:space:]]#--[[:space:]]#*)==0\;35=2\;37' zstyle ':completion:*:*:ccs:*:model-profiles' list-colors '=(#b)(default|glm|glmt|kimi|[^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;32=2\;37' zstyle ':completion:*:*:ccs:*:account-profiles' list-colors '=(#b)([^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;33=2\;37' @@ -34,6 +34,7 @@ _ccs() { 'api:Manage API profiles (create/remove)' 'cliproxy:Manage CLIProxy variants and binary' 'doctor:Run health check and diagnostics' + 'env:Export env vars for third-party tools' 'sync:Sync delegation commands and skills' 'update:Update CCS to latest version' ) @@ -124,6 +125,13 @@ _ccs() { _arguments \ '(- *)'{-h,--help}'[Show help for doctor command]' ;; + env) + _arguments \ + '--format[Output format]:format:(openai anthropic raw)' \ + '--shell[Shell syntax]:shell:(bash fish powershell)' \ + '(- *)'{-h,--help}'[Show help]' \ + '1:profile:($proxy_profiles)' + ;; gemini|codex|agy|qwen) _arguments \ '--auth[Authenticate only]' \ diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index a29754fa..d5e5e50b 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -18,6 +18,7 @@ type OutputFormat = 'openai' | 'anthropic' | 'raw'; const VALID_FORMATS: OutputFormat[] = ['openai', 'anthropic', 'raw']; const VALID_SHELLS: ShellType[] = ['bash', 'fish', 'powershell']; +const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; /** Auto-detect shell from environment */ export function detectShell(flag?: string): ShellType { @@ -231,7 +232,7 @@ export async function handleEnvCommand(args: string[]): Promise { // Output shell-formatted exports to stdout for (const [key, value] of Object.entries(output)) { - if (value) { + if (value && VALID_ENV_KEY.test(key)) { console.log(formatExportLine(shell, key, value)); } } From 041e1c6cc2398c7c7311dac81f93eb39b983faf1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 06:41:33 +0700 Subject: [PATCH 04/11] chore: update lockfiles --- bun.lock | 1 + ui/bun.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index e9396b8b..fd68ef78 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@kaitranntt/ccs", diff --git a/ui/bun.lock b/ui/bun.lock index 2756da98..c2e29de1 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "ui", From 44b3152d347a7f0c96af4e2e4cd8027ea30634fc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 06:46:12 +0700 Subject: [PATCH 05/11] fix(env): address all PR review feedback - Add settings profiles to zsh env completion (was proxy-only) - Document intentional ANTHROPIC_MODEL omission in transformToOpenAI - Use getCcsDir() in error hint instead of hardcoded ~/.ccs/ - Export parseFlag and add 5 unit tests for flag parsing - Add fish and PowerShell single-quote escaping tests --- scripts/completion/ccs.zsh | 2 +- src/commands/env-command.ts | 9 ++++-- tests/unit/commands/env-command.test.ts | 39 ++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index 0ab058f4..225bf2b4 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -130,7 +130,7 @@ _ccs() { '--format[Output format]:format:(openai anthropic raw)' \ '--shell[Shell syntax]:shell:(bash fish powershell)' \ '(- *)'{-h,--help}'[Show help]' \ - '1:profile:($proxy_profiles)' + '1:profile:($proxy_profiles ${(k)settings_profiles_described})' ;; gemini|codex|agy|qwen) _arguments \ diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index d5e5e50b..7019e0e1 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -12,6 +12,7 @@ import { getEffectiveEnvVars } from '../cliproxy/config/env-builder'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { isUnifiedMode, loadUnifiedConfig } from '../config/unified-config-loader'; import { expandPath } from '../utils/helpers'; +import { getCcsDir } from '../utils/config-manager'; type ShellType = 'bash' | 'fish' | 'powershell'; type OutputFormat = 'openai' | 'anthropic' | 'raw'; @@ -46,7 +47,9 @@ 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 + * model routing, so OPENAI_MODEL is unnecessary for OpenAI-compatible tools. */ export function transformToOpenAI(envVars: Record): Record { const baseUrl = envVars['ANTHROPIC_BASE_URL'] || ''; const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || ''; @@ -58,7 +61,7 @@ export function transformToOpenAI(envVars: Record): Record a.startsWith(`--${flag}=`)); if (eqMatch) return eqMatch.split('=').slice(1).join('='); @@ -190,7 +193,7 @@ export async function handleEnvCommand(args: string[]): Promise { if (!resolved) { console.error(fail(`Profile '${profile}' not found.`)); console.error(dim(' Available CLIProxy profiles: ' + CLIPROXY_PROFILES.join(', '))); - console.error(dim(' Check ~/.ccs/config.yaml for custom profiles.')); + console.error(dim(` Check ${getCcsDir()}/config.yaml for custom profiles.`)); process.exit(1); } envVars = resolved; diff --git a/tests/unit/commands/env-command.test.ts b/tests/unit/commands/env-command.test.ts index efcae254..1bf01e5c 100644 --- a/tests/unit/commands/env-command.test.ts +++ b/tests/unit/commands/env-command.test.ts @@ -4,7 +4,12 @@ * Tests pure utility functions: detectShell, formatExportLine, transformToOpenAI */ import { describe, it, expect, afterEach } from 'bun:test'; -import { detectShell, formatExportLine, transformToOpenAI } from '../../../src/commands/env-command'; +import { + detectShell, + formatExportLine, + transformToOpenAI, + parseFlag, +} from '../../../src/commands/env-command'; describe('env-command', () => { describe('detectShell', () => { @@ -91,6 +96,16 @@ describe('env-command', () => { "export TOKEN='safe$(whoami)'" ); }); + + it('escapes single quotes in fish values', () => { + expect(formatExportLine('fish', 'VAL', "it's here")).toBe("set -gx VAL 'it\\'s here'"); + }); + + it('escapes single quotes in powershell values', () => { + expect(formatExportLine('powershell', 'VAL', "it's here")).toBe( + "$env:VAL = 'it''s here'" + ); + }); }); describe('transformToOpenAI', () => { @@ -131,4 +146,26 @@ describe('env-command', () => { expect(result['ANTHROPIC_MAX_TOKENS']).toBeUndefined(); }); }); + + describe('parseFlag', () => { + it('parses --flag=value style', () => { + expect(parseFlag(['--format=openai'], 'format')).toBe('openai'); + }); + + it('parses --flag value style', () => { + expect(parseFlag(['--format', 'openai'], 'format')).toBe('openai'); + }); + + it('handles values containing =', () => { + expect(parseFlag(['--format=key=val=ue'], 'format')).toBe('key=val=ue'); + }); + + it('returns undefined for missing flag', () => { + expect(parseFlag(['--shell', 'bash'], 'format')).toBeUndefined(); + }); + + it('does not consume next flag as value', () => { + expect(parseFlag(['--format', '--shell'], 'format')).toBeUndefined(); + }); + }); }); From d5c03d1f2d2ad600b4106a9a2fb38a028d099338 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 06:52:21 +0700 Subject: [PATCH 06/11] 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 --- src/commands/env-command.ts | 43 +++++++++++++++++++------ tests/unit/commands/env-command.test.ts | 41 +++++++++++++++++++++-- 2 files changed, 73 insertions(+), 11 deletions(-) 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'); + }); + }); }); From 3f5ecd4d6963a25ce909cd7e17fcca610d9dc978 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 07:09:58 +0700 Subject: [PATCH 07/11] fix(env): address P1-P3 review items from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: Fix bash completion $cliproxy_profiles scoping — inline profiles in env block since variable is only defined at COMP_CWORD=1 scope - P2: Detect account-based profiles and show specific error message instead of generic "not found" - P2: Show `ccs migrate` hint when unified mode is disabled and settings profile resolution fails - P2: transformToOpenAI omits empty entries at transform time instead of relying on output filter (removes fragile coupling) - P3: Add zsh and auto to --shell completions across all 4 shells; map --shell zsh to bash in command handler since syntax is identical - P3: Auto-detect PowerShell from SHELL containing pwsh on non-Windows - Tests: 33 pass (+1 pwsh detection test, updated transform assertions) --- scripts/completion/ccs.bash | 6 ++-- scripts/completion/ccs.fish | 2 +- scripts/completion/ccs.ps1 | 2 +- scripts/completion/ccs.zsh | 2 +- src/commands/env-command.ts | 38 +++++++++++++++++++------ tests/unit/commands/env-command.test.ts | 13 +++++---- 6 files changed, 43 insertions(+), 20 deletions(-) diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index a9b1d392..815f27a1 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -155,8 +155,8 @@ _ccs_completion() { if [[ ${COMP_WORDS[1]} == "env" ]]; then case "${prev}" in env) - # Complete with profile names and flags - local env_opts="--format --shell --help -h $cliproxy_profiles" + # Complete with profile names and flags (inline profiles since $cliproxy_profiles is out of scope) + local env_opts="--format --shell --help -h gemini codex agy qwen" if [[ -f ~/.ccs/config.json ]]; then env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)" fi @@ -168,7 +168,7 @@ _ccs_completion() { return 0 ;; --shell) - COMPREPLY=( $(compgen -W "bash fish powershell" -- ${cur}) ) + COMPREPLY=( $(compgen -W "auto bash zsh fish powershell" -- ${cur}) ) return 0 ;; *) diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index 9593e736..5f81c5d0 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -176,7 +176,7 @@ complete -c ccs -n '__fish_seen_subcommand_from doctor' -s h -l help -d 'Show he complete -c ccs -n '__fish_seen_subcommand_from env' -l format -d 'Output format' complete -c ccs -n '__fish_seen_subcommand_from env; and __fish_seen_argument -l format' -a 'openai anthropic raw' -d 'Format' complete -c ccs -n '__fish_seen_subcommand_from env' -l shell -d 'Shell syntax' -complete -c ccs -n '__fish_seen_subcommand_from env; and __fish_seen_argument -l shell' -a 'bash fish powershell' -d 'Shell' +complete -c ccs -n '__fish_seen_subcommand_from env; and __fish_seen_argument -l shell' -a 'auto bash zsh fish powershell' -d 'Shell' complete -c ccs -n '__fish_seen_subcommand_from env' -s h -l help -d 'Show help for env command' # ============================================================================ diff --git a/scripts/completion/ccs.ps1 b/scripts/completion/ccs.ps1 index 9f5ac508..daad20d3 100644 --- a/scripts/completion/ccs.ps1 +++ b/scripts/completion/ccs.ps1 @@ -23,7 +23,7 @@ Register-ArgumentCompleter -CommandName ccs -ScriptBlock { $updateFlags = @('--force', '--beta', '--dev', '--help', '-h') $envFlags = @('--format', '--shell', '--help', '-h') $envFormats = @('openai', 'anthropic', 'raw') - $envShells = @('bash', 'fish', 'powershell') + $envShells = @('auto', 'bash', 'zsh', 'fish', 'powershell') $shellCompletionFlags = @('--bash', '--zsh', '--fish', '--powershell') $listFlags = @('--verbose', '--json') $removeFlags = @('--yes', '-y') diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index 225bf2b4..3ae11d32 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -128,7 +128,7 @@ _ccs() { env) _arguments \ '--format[Output format]:format:(openai anthropic raw)' \ - '--shell[Shell syntax]:shell:(bash fish powershell)' \ + '--shell[Shell syntax]:shell:(auto bash zsh fish powershell)' \ '(- *)'{-h,--help}'[Show help]' \ '1:profile:($proxy_profiles ${(k)settings_profiles_described})' ;; diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index 92bf626f..843b8c01 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -13,6 +13,7 @@ import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { isUnifiedMode, loadUnifiedConfig } from '../config/unified-config-loader'; import { expandPath } from '../utils/helpers'; import { getCcsDir } from '../utils/config-manager'; +import { ProfileRegistry } from '../auth/profile-registry'; type ShellType = 'bash' | 'fish' | 'powershell'; type OutputFormat = 'openai' | 'anthropic' | 'raw'; @@ -28,7 +29,7 @@ export function detectShell(flag?: string): ShellType { } const shell = process.env['SHELL'] || ''; if (shell.includes('fish')) return 'fish'; - if (process.platform === 'win32') return 'powershell'; + if (shell.includes('pwsh') || process.platform === 'win32') return 'powershell'; return 'bash'; } @@ -54,11 +55,12 @@ export function transformToOpenAI(envVars: Record): Record = { - OPENAI_API_KEY: apiKey, - OPENAI_BASE_URL: baseUrl, - LOCAL_ENDPOINT: baseUrl, - }; + const result: Record = {}; + if (apiKey) result['OPENAI_API_KEY'] = apiKey; + if (baseUrl) { + result['OPENAI_BASE_URL'] = baseUrl; + result['LOCAL_ENDPOINT'] = baseUrl; + } if (model) result['OPENAI_MODEL'] = model; return result; } @@ -195,7 +197,8 @@ export async function handleEnvCommand(args: string[]): Promise { const format = formatStr as OutputFormat; const shellStr = parseFlag(args, 'shell') || 'auto'; - const shell = detectShell(shellStr); + // zsh uses the same syntax as bash + const shell = detectShell(shellStr === 'zsh' ? 'bash' : shellStr); // Resolve env vars based on profile type let envVars: Record = {}; @@ -212,9 +215,28 @@ export async function handleEnvCommand(args: string[]): Promise { // Settings-based profile (glm, kimi, custom API) const resolved = resolveSettingsProfile(profile); if (!resolved) { + // Check if it's an account-based profile + const registry = new ProfileRegistry(); + const allProfiles = registry.getAllProfiles(); + if (allProfiles[profile]) { + console.error( + fail( + `'${profile}' is an account-based profile. ` + + '`ccs env` only supports CLIProxy and settings profiles.' + ) + ); + process.exit(1); + } + console.error(fail(`Profile '${profile}' not found.`)); console.error(dim(' Available CLIProxy profiles: ' + CLIPROXY_PROFILES.join(', '))); - console.error(dim(` Check ${getCcsDir()}/config.yaml for custom profiles.`)); + if (!isUnifiedMode()) { + console.error( + dim(' Settings profiles require unified config. Run `ccs migrate` to upgrade.') + ); + } else { + console.error(dim(` Check ${getCcsDir()}/config.yaml for custom profiles.`)); + } process.exit(1); } envVars = resolved; diff --git a/tests/unit/commands/env-command.test.ts b/tests/unit/commands/env-command.test.ts index d1c0aeca..d70a140c 100644 --- a/tests/unit/commands/env-command.test.ts +++ b/tests/unit/commands/env-command.test.ts @@ -60,6 +60,11 @@ describe('env-command', () => { process.env['SHELL'] = '/bin/bash'; expect(detectShell('invalid')).toBe('bash'); }); + + it('auto-detects powershell from SHELL containing pwsh', () => { + process.env['SHELL'] = '/usr/local/bin/pwsh'; + expect(detectShell('auto')).toBe('powershell'); + }); }); describe('formatExportLine', () => { @@ -128,11 +133,7 @@ describe('env-command', () => { it('handles missing source vars gracefully', () => { const result = transformToOpenAI({}); - expect(result).toEqual({ - OPENAI_API_KEY: '', - OPENAI_BASE_URL: '', - LOCAL_ENDPOINT: '', - }); + expect(result).toEqual({}); }); it('only extracts relevant vars', () => { @@ -143,7 +144,7 @@ describe('env-command', () => { DISABLE_TELEMETRY: '1', }); - // Should only have 3 keys (no OPENAI_MODEL when ANTHROPIC_MODEL absent) + // OPENAI_API_KEY + OPENAI_BASE_URL + LOCAL_ENDPOINT (no OPENAI_MODEL when ANTHROPIC_MODEL absent) expect(Object.keys(result)).toHaveLength(3); expect(result['ANTHROPIC_MAX_TOKENS']).toBeUndefined(); }); From 38bd562687865c2cb523734509143390f83a5dcc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 07:29:36 +0700 Subject: [PATCH 08/11] docs: update local docs for ccs env command - codebase-summary: add env-command.ts and test file, update test count - system-architecture: add env-command to CLI commands list - project-overview-pdr: add FR-011 third-party tool integration, v7.39 - project-roadmap: add Phase 15 and v7.39 milestone --- docs/codebase-summary.md | 11 +++++++---- docs/project-overview-pdr.md | 19 ++++++++++++++++++- docs/project-roadmap.md | 4 +++- docs/system-architecture.md | 7 ++++--- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 4ef7fc7b..43fcb828 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -48,6 +48,7 @@ src/ │ ├── config-command.ts # Config management commands │ ├── config-image-analysis-command.ts # Image analysis hook config (NEW v7.34) │ ├── doctor-command.ts # Health diagnostics +│ ├── env-command.ts # Export shell env vars for third-party tools (v7.39) │ ├── help-command.ts # Help text generation │ ├── install-command.ts # Install/uninstall logic │ ├── shell-completion-command.ts @@ -465,10 +466,12 @@ export type { ProviderEditorProps } from './provider-editor'; ``` tests/ -├── unit/ # Unit tests (6 core test files) +├── unit/ # Unit tests (7 core test files) │ ├── data-aggregator.test.ts │ ├── cliproxy/ │ │ └── remote-proxy-client.test.ts +│ ├── commands/ +│ │ └── env-command.test.ts │ ├── jsonl-parser.test.ts │ ├── model-pricing.test.ts │ ├── unified-config.test.ts @@ -487,12 +490,12 @@ tests/ | Metric | Value | |--------|-------| -| Total Tests | 1407 | -| Passing | 1407 | +| Total Tests | 1440 | +| Passing | 1440 | | Skipped | 6 | | Failed | 0 | | Coverage Threshold | 90% | -| Test Files | 40+ | +| Test Files | 41 | --- diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 2fd44000..30614026 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -115,6 +115,13 @@ CCS provides: - Entrypoint with privilege dropping and usage help - Environment variable configuration support +### FR-011: Third-Party Tool Integration +- Export shell-evaluable env vars via `ccs env` command +- Support OpenAI, Anthropic, raw output formats +- Auto-detect shell (bash/zsh, fish, PowerShell) from $SHELL +- Security: single-quoted output, key sanitization, shell-specific escaping +- Cross-platform compatibility (macOS, Linux, Windows) + --- ## Non-Functional Requirements @@ -192,7 +199,7 @@ CCS provides: | Startup time | < 100ms | Achieved | | Dashboard load | < 2s | Achieved | | Error rate | < 1% | Achieved | -| Test coverage | > 90% | 90% (1407 tests, 6 skipped) | +| Test coverage | > 90% | 90% (1440 tests, 6 skipped) | | File size compliance | 100% < 200 lines | 95% | --- @@ -261,6 +268,16 @@ CCS provides: - [x] Quota 429 rate limit handling improvements - [x] WebSocket maxPayload limit (DoS prevention) +### v7.39 Release (Complete) +- [x] `ccs env` command for third-party tool integration (OpenCode, Cursor, Continue) +- [x] Multi-format output: openai, anthropic, raw +- [x] Multi-shell support: bash/zsh, fish, PowerShell (auto-detected) +- [x] CLIProxy profile support (gemini, codex, agy, qwen) +- [x] Settings profile support (glm, kimi, custom API) +- [x] Security: single-quoted output, key sanitization, shell-specific escaping +- [x] Shell completion updated (bash, zsh, fish, PowerShell) +- [x] 33 unit tests for env command + ### v8.0 Release (Planned - Q1 2026) - [ ] Multiple CLIProxyAPI instances (load balancing, failover) - [ ] Native git worktree support diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 2aa857fc..7f257d11 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -26,13 +26,14 @@ All major modularization work is complete. The codebase evolved from monolithic | 12 | Hybrid Quota Management | `quota-manager.ts`, `quota-fetcher.ts` (v7.14) | | 13 | Docker Support | `docker/` directory with Dockerfile, Compose, entrypoint | | 14 | Image Analysis Hook | Vision proxying via CLIProxy transformers (v7.34) | +| 15 | Third-Party Tool Integration | `ccs env` command with multi-format export (v7.39) | **Metrics Achieved**: - Files >500 lines: 12 -> 5 (-58%) - UI files >200 lines: 28 -> 8 (-71%) - Barrel exports: 5 -> 39 (+680%) - Test coverage: 0% -> 90% -- Total tests: 1407 (6 skipped) +- Total tests: 1440 (6 skipped) --- @@ -170,6 +171,7 @@ worktrees: | Hybrid Quota Management | COMPLETE | v7.14 | | Docker Support (PR #345) | COMPLETE | v7.23 | | Image Analysis Hook | COMPLETE | v7.34 | +| Third-Party Tool Integration | COMPLETE | v7.39 | | Critical Bug Fixes (#158, #155, #124) | PLANNED | Q1 2026 | | Multiple CLIProxyAPI Instances | PLANNED | Q1 2026 | | Git Worktree Support | PLANNED | Q2 2026 | diff --git a/docs/system-architecture.md b/docs/system-architecture.md index cca04f5b..10b55a2a 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -116,9 +116,10 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi | commands/ | | auth/ | | config/ | |------------------| |------------------| |------------------| | doctor-command | | account-switcher | | unified-config- | - | help-command | | profile-detector | | loader | - | install-command | | commands/ | | migration-manager| - | sync-command | +------------------+ +------------------+ + | env-command | | profile-detector | | loader | + | help-command | | commands/ | | migration-manager| + | install-command | +------------------+ +------------------+ + | sync-command | | update-command | +------------------+ | | | From 6d9351dcbc1baa4135d75361301e36cafc3556a3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 10:50:55 +0700 Subject: [PATCH 09/11] fix(env): add missing CLIProxy profiles to bash completion and shell validation - Add iflow, kiro, ghcp, claude to bash completion env block - Add --shell flag validation matching --format pattern - Add backtick injection test case --- scripts/completion/ccs.bash | 2 +- src/commands/env-command.ts | 5 +++++ tests/unit/commands/env-command.test.ts | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index 815f27a1..b9eb965e 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -156,7 +156,7 @@ _ccs_completion() { case "${prev}" in env) # Complete with profile names and flags (inline profiles since $cliproxy_profiles is out of scope) - local env_opts="--format --shell --help -h gemini codex agy qwen" + local env_opts="--format --shell --help -h gemini codex agy qwen iflow kiro ghcp claude" if [[ -f ~/.ccs/config.json ]]; then env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)" fi diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index 843b8c01..e461c32e 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -197,6 +197,11 @@ export async function handleEnvCommand(args: string[]): Promise { const format = formatStr as OutputFormat; const shellStr = parseFlag(args, 'shell') || 'auto'; + const validShellInputs = ['auto', 'bash', 'zsh', 'fish', 'powershell']; + if (!validShellInputs.includes(shellStr)) { + console.error(fail(`Invalid shell: ${shellStr}. Use: ${validShellInputs.join(', ')}`)); + process.exit(1); + } // zsh uses the same syntax as bash const shell = detectShell(shellStr === 'zsh' ? 'bash' : shellStr); diff --git a/tests/unit/commands/env-command.test.ts b/tests/unit/commands/env-command.test.ts index d70a140c..b6da6236 100644 --- a/tests/unit/commands/env-command.test.ts +++ b/tests/unit/commands/env-command.test.ts @@ -103,6 +103,12 @@ describe('env-command', () => { ); }); + it('prevents backtick injection in values', () => { + expect(formatExportLine('bash', 'TOKEN', 'safe`whoami`')).toBe( + "export TOKEN='safe`whoami`'" + ); + }); + it('escapes single quotes in fish values', () => { expect(formatExportLine('fish', 'VAL', "it's here")).toBe("set -gx VAL 'it'\\''s here'"); }); From a98f4a54278f9f8d9e4b30ccccc900514c08b32f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 11:02:34 +0700 Subject: [PATCH 10/11] fix(env): sync CLIProxy profiles across all shell completions and improve error messages - Add profile name completions to fish env subcommand - Add iflow, kiro, ghcp, claude to zsh proxy_profiles and PS1 cliproxyProfiles - Distinguish non-API profile type error from "profile not found" --- scripts/completion/ccs.fish | 1 + scripts/completion/ccs.ps1 | 2 +- scripts/completion/ccs.zsh | 4 ++++ src/commands/env-command.ts | 11 ++++++++++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index 5f81c5d0..ea58c2d7 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -173,6 +173,7 @@ complete -c ccs -n '__fish_seen_subcommand_from update' -s h -l help -d 'Show he complete -c ccs -n '__fish_seen_subcommand_from doctor' -s h -l help -d 'Show help for doctor command' # env command completions +complete -c ccs -n '__fish_seen_subcommand_from env; and not __fish_seen_argument -l format -l shell' -a 'gemini codex agy qwen iflow kiro ghcp claude' -d '[proxy] CLIProxy profile' complete -c ccs -n '__fish_seen_subcommand_from env' -l format -d 'Output format' complete -c ccs -n '__fish_seen_subcommand_from env; and __fish_seen_argument -l format' -a 'openai anthropic raw' -d 'Format' complete -c ccs -n '__fish_seen_subcommand_from env' -l shell -d 'Shell syntax' diff --git a/scripts/completion/ccs.ps1 b/scripts/completion/ccs.ps1 index daad20d3..500cf988 100644 --- a/scripts/completion/ccs.ps1 +++ b/scripts/completion/ccs.ps1 @@ -13,7 +13,7 @@ Register-ArgumentCompleter -CommandName ccs -ScriptBlock { param($commandName, $wordToComplete, $commandAst, $fakeBoundParameters) $commands = @('auth', 'api', 'cliproxy', 'doctor', 'env', 'sync', 'update', '--help', '--version', '--shell-completion', '-h', '-v', '-sc') - $cliproxyProfiles = @('gemini', 'codex', 'agy', 'qwen') + $cliproxyProfiles = @('gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp', 'claude') $authCommands = @('create', 'list', 'show', 'remove', 'default', '--help', '-h') $apiCommands = @('create', 'list', 'remove', '--help', '-h') $cliproxyCommands = @('create', 'list', 'remove', '--install', '--latest', '--help', '-h') diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index 3ae11d32..fb4f9f36 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -45,6 +45,10 @@ _ccs() { 'codex:OpenAI Codex (OAuth)' 'agy:Antigravity (OAuth)' 'qwen:Qwen Code (OAuth)' + 'iflow:iFlow (OAuth)' + 'kiro:Kiro (OAuth)' + 'ghcp:GitHub Copilot (OAuth)' + 'claude:Claude Direct (OAuth)' ) # Define known settings profiles with descriptions diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index e461c32e..d652f1b0 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -111,7 +111,16 @@ function resolveSettingsProfile(profileName: string): Record | n const profileConfig = config.profiles?.[profileName]; if (!profileConfig) return null; - if (profileConfig.type === 'api' && profileConfig.settings) { + if (profileConfig.type !== 'api') { + console.error( + fail( + `Profile '${profileName}' is type '${profileConfig.type}', not a settings-based API profile.` + ) + ); + process.exit(1); + } + + if (profileConfig.settings) { const settingsPath = expandPath(profileConfig.settings); const env = loadSettingsFromFile(settingsPath); if (Object.keys(env).length > 0) return env; From b96eacfc06a97e89796620c1ea675aaac290e427 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 11:20:25 +0700 Subject: [PATCH 11/11] fix(env): improve empty profile UX and consolidate shell validation constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Return {} instead of null for valid-but-empty settings profiles - Consolidate VALID_SHELLS and validShellInputs into VALID_SHELL_INPUTS - Fix docs test count 33 → 34 --- docs/project-overview-pdr.md | 2 +- src/commands/env-command.ts | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 30614026..f6384588 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -276,7 +276,7 @@ CCS provides: - [x] Settings profile support (glm, kimi, custom API) - [x] Security: single-quoted output, key sanitization, shell-specific escaping - [x] Shell completion updated (bash, zsh, fish, PowerShell) -- [x] 33 unit tests for env command +- [x] 34 unit tests for env command ### v8.0 Release (Planned - Q1 2026) - [ ] Multiple CLIProxyAPI instances (load balancing, failover) diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index d652f1b0..6ef1f48a 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -20,6 +20,7 @@ type OutputFormat = 'openai' | 'anthropic' | 'raw'; const VALID_FORMATS: OutputFormat[] = ['openai', 'anthropic', 'raw']; const VALID_SHELLS: ShellType[] = ['bash', 'fish', 'powershell']; +const VALID_SHELL_INPUTS = ['auto', 'bash', 'zsh', 'fish', 'powershell'] as const; const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; /** Auto-detect shell from environment */ @@ -122,11 +123,10 @@ function resolveSettingsProfile(profileName: string): Record | n if (profileConfig.settings) { const settingsPath = expandPath(profileConfig.settings); - const env = loadSettingsFromFile(settingsPath); - if (Object.keys(env).length > 0) return env; + return loadSettingsFromFile(settingsPath); } - return null; + return {}; } /** Show help for env command */ @@ -206,9 +206,8 @@ export async function handleEnvCommand(args: string[]): Promise { const format = formatStr as OutputFormat; const shellStr = parseFlag(args, 'shell') || 'auto'; - const validShellInputs = ['auto', 'bash', 'zsh', 'fish', 'powershell']; - if (!validShellInputs.includes(shellStr)) { - console.error(fail(`Invalid shell: ${shellStr}. Use: ${validShellInputs.join(', ')}`)); + if (!VALID_SHELL_INPUTS.includes(shellStr as (typeof VALID_SHELL_INPUTS)[number])) { + console.error(fail(`Invalid shell: ${shellStr}. Use: ${VALID_SHELL_INPUTS.join(', ')}`)); process.exit(1); } // zsh uses the same syntax as bash