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(); + }); + }); +});