diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 4d7ec3ac..0c5de9c5 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -34,7 +34,7 @@ The main CLI is organized into domain-specific modules with barrel exports. ``` src/ -├── ccs.ts # Main entry point & CLI router +├── ccs.ts # Main entry point & profile execution flow ├── types/ # TypeScript type definitions │ ├── index.ts # Barrel export (aggregates all types) │ ├── cli.ts # CLI types (ParsedArgs, ExitCode) @@ -44,13 +44,19 @@ src/ │ └── utils.ts # Utility types (ErrorCode, LogLevel) │ ├── commands/ # CLI command handlers +│ ├── api-command/ # API profile subcommands (split facade + handlers) +│ │ ├── index.ts # API command facade/router +│ │ ├── shared.ts # Shared API arg parsing helpers +│ │ └── [subcommand files...] │ ├── cliproxy-command.ts # CLIProxy subcommand handling │ ├── config-command.ts # Config management commands │ ├── config-image-analysis-command.ts # Image analysis hook config (NEW v7.34) +│ ├── named-command-router.ts # Reusable named-command dispatcher │ ├── 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 +│ ├── root-command-router.ts # Extracted top-level command dispatch from ccs.ts │ ├── shell-completion-command.ts │ ├── sync-command.ts # Symlink synchronization │ ├── update-command.ts # Self-update logic diff --git a/src/ccs.ts b/src/ccs.ts index 704ac9bb..bcf53769 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -40,15 +40,7 @@ import { isCopilotSubcommandToken } from './copilot/constants'; // Import centralized error handling import { handleError, runCleanup } from './errors'; - -// Import extracted command handlers -import { handleVersionCommand } from './commands/version-command'; -import { handleHelpCommand } from './commands/help-command'; -import { handleInstallCommand, handleUninstallCommand } from './commands/install-command'; -import { handleDoctorCommand } from './commands/doctor-command'; -import { handleSyncCommand } from './commands/sync-command'; -import { handleShellCompletionCommand } from './commands/shell-completion-command'; -import { handleUpdateCommand } from './commands/update-command'; +import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions import { @@ -462,119 +454,7 @@ async function main(): Promise { console.warn('[!] Recovery failed:', (err as Error).message); } - // Special case: migrate command - if (firstArg === 'migrate' || firstArg === '--migrate') { - const { handleMigrateCommand, printMigrateHelp } = await import('./commands/migrate-command'); - const migrateArgs = args.slice(1); - - if (migrateArgs.includes('--help') || migrateArgs.includes('-h')) { - printMigrateHelp(); - return; - } - - await handleMigrateCommand(migrateArgs); - return; - } - - // Special case: update command - if (firstArg === 'update' || firstArg === '--update') { - const updateArgs = args.slice(1); - - // Handle --help for update command - if (updateArgs.includes('--help') || updateArgs.includes('-h')) { - console.log(''); - console.log('Usage: ccs update [options]'); - console.log(''); - console.log('Options:'); - console.log(' --force Force reinstall current version'); - console.log(' --beta, --dev Install from dev channel (unstable)'); - console.log(' --help, -h Show this help message'); - console.log(''); - console.log('Examples:'); - console.log(' ccs update Update to latest stable'); - console.log(' ccs update --force Force reinstall'); - console.log(' ccs update --beta Install dev channel'); - console.log(''); - return; - } - - const forceFlag = updateArgs.includes('--force'); - const betaFlag = updateArgs.includes('--beta') || updateArgs.includes('--dev'); - await handleUpdateCommand({ force: forceFlag, beta: betaFlag }); - return; - } - - const commandAliases: Record = { - '--version': 'version', - '-v': 'version', - '--help': 'help', - '-h': 'help', - '--doctor': 'doctor', - '--sync': 'sync', - '--cleanup': 'cleanup', - '--setup': 'setup', - }; - - const normalizedFirstArg = commandAliases[firstArg] || firstArg; - - const earlyCommandHandlers: Record Promise> = { - version: async () => handleVersionCommand(), - help: async () => handleHelpCommand(), - '--install': async () => handleInstallCommand(), - '--uninstall': async () => handleUninstallCommand(), - '--shell-completion': async () => handleShellCompletionCommand(args.slice(1)), - '-sc': async () => handleShellCompletionCommand(args.slice(1)), - doctor: async () => handleDoctorCommand(args.slice(1)), - sync: async () => handleSyncCommand(), - cleanup: async () => { - const { handleCleanupCommand } = await import('./commands/cleanup-command'); - await handleCleanupCommand(args.slice(1)); - }, - auth: async () => { - const AuthCommandsModule = await import('./auth/auth-commands'); - const AuthCommands = AuthCommandsModule.default; - const authCommands = new AuthCommands(); - await authCommands.route(args.slice(1)); - }, - api: async () => { - const { handleApiCommand } = await import('./commands/api-command'); - await handleApiCommand(args.slice(1)); - }, - cliproxy: async () => { - const { handleCliproxyCommand } = await import('./commands/cliproxy-command'); - await handleCliproxyCommand(args.slice(1)); - }, - config: async () => { - const { handleConfigCommand } = await import('./commands/config-command'); - await handleConfigCommand(args.slice(1)); - }, - tokens: async () => { - const { handleTokensCommand } = await import('./commands/tokens-command'); - const exitCode = await handleTokensCommand(args.slice(1)); - process.exit(exitCode); - }, - persist: async () => { - const { handlePersistCommand } = await import('./commands/persist-command'); - await handlePersistCommand(args.slice(1)); - }, - env: async () => { - const { handleEnvCommand } = await import('./commands/env-command'); - await handleEnvCommand(args.slice(1)); - }, - setup: async () => { - const { handleSetupCommand } = await import('./commands/setup-command'); - await handleSetupCommand(args.slice(1)); - }, - cursor: async () => { - const { handleCursorCommand } = await import('./commands/cursor-command'); - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - }, - }; - - const earlyCommandHandler = earlyCommandHandlers[normalizedFirstArg]; - if (earlyCommandHandler) { - await earlyCommandHandler(); + if (await tryHandleRootCommand(args)) { return; } diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts deleted file mode 100644 index 535e2eb7..00000000 --- a/src/commands/api-command.ts +++ /dev/null @@ -1,1031 +0,0 @@ -/** - * API Command Handler - * - * Manages CCS API profiles for custom API providers. - * Commands: create, list, remove - * - * CLI parsing and output formatting only. - * Business logic delegated to src/api/services/. - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { - initUI, - header, - subheader, - color, - dim, - ok, - fail, - warn, - info, - table, - infoBox, -} from '../utils/ui'; -import { InteractivePrompt } from '../utils/prompt'; -import { - validateApiName, - validateUrl, - getUrlWarning, - sanitizeBaseUrl, - apiProfileExists, - listApiProfiles, - createApiProfile, - removeApiProfile, - getApiProfileNames, - isUsingUnifiedConfig, - isOpenRouterUrl, - pickOpenRouterModel, - PROVIDER_PRESETS, - getPresetById, - getPresetAliases, - getPresetIds, - discoverApiProfileOrphans, - registerApiProfileOrphans, - copyApiProfile, - exportApiProfile, - importApiProfileBundle, - type ModelMapping, - type ProviderPreset, -} from '../api/services'; -import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync'; -import type { TargetType } from '../targets/target-adapter'; -import { extractOption, hasAnyFlag } from './arg-extractor'; - -interface ApiCommandArgs { - name?: string; - baseUrl?: string; - apiKey?: string; - model?: string; - preset?: string; - target?: TargetType; - force?: boolean; - yes?: boolean; - errors: string[]; -} - -const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; -const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset', '--target'] as const; -const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; -const API_VALUE_FLAG_SET = new Set(API_VALUE_FLAGS); - -function sanitizeHelpText(value: string): string { - return value - .replace(/[\r\n\t]+/g, ' ') - .replace(/[\x00-\x1f\x7f]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { - const presetId = sanitizeHelpText(preset.id) || 'unknown'; - const paddedId = presetId.padEnd(idWidth); - const presetName = sanitizeHelpText(preset.name) || 'Unknown preset'; - const presetDescription = sanitizeHelpText(preset.description) || 'No description'; - return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`; -} - -function applyRepeatedOption( - args: string[], - flags: readonly string[], - onValue: (value: string) => void, - onMissing: () => void -): string[] { - let remaining = [...args]; - - while (true) { - const extracted = extractOption(remaining, flags, { - allowDashValue: true, - knownFlags: API_KNOWN_FLAGS, - }); - if (!extracted.found) { - return remaining; - } - - if (extracted.missingValue || !extracted.value) { - onMissing(); - } else { - onValue(extracted.value); - } - - remaining = extracted.remainingArgs; - } -} - -function extractPositionalArgs(args: string[]): string[] { - const positionals: string[] = []; - - for (let i = 0; i < args.length; i++) { - const token = args[i]; - if (token === '--') { - positionals.push(...args.slice(i + 1)); - break; - } - - if (token.startsWith('-')) { - if (!token.includes('=') && API_VALUE_FLAG_SET.has(token)) { - const next = args[i + 1]; - if (next && !next.startsWith('-')) { - i++; - } - } - continue; - } - - positionals.push(token); - } - - return positionals; -} - -function parseTargetValue(value: string): TargetType | null { - const normalized = value.trim().toLowerCase(); - if (normalized === 'claude' || normalized === 'droid') { - return normalized; - } - return null; -} - -function parseOptionalTargetFlag( - args: string[], - knownFlags: readonly string[] -): { target?: TargetType; remainingArgs: string[]; errors: string[] } { - const extracted = extractOption(args, ['--target'], { - allowDashValue: true, - knownFlags, - }); - if (!extracted.found) { - return { remainingArgs: args, errors: [] }; - } - if (extracted.missingValue || !extracted.value) { - return { remainingArgs: extracted.remainingArgs, errors: ['Missing value for --target'] }; - } - const target = parseTargetValue(extracted.value); - if (!target) { - return { - remainingArgs: extracted.remainingArgs, - errors: [`Invalid --target value "${extracted.value}". Use: claude or droid`], - }; - } - return { target, remainingArgs: extracted.remainingArgs, errors: [] }; -} - -/** Parse command line arguments for api commands */ -export function parseApiCommandArgs(args: string[]): ApiCommandArgs { - const result: ApiCommandArgs = { - force: hasAnyFlag(args, ['--force']), - yes: hasAnyFlag(args, ['--yes', '-y']), - errors: [], - }; - - let remaining = [...args]; - - remaining = applyRepeatedOption( - remaining, - ['--base-url'], - (value) => { - result.baseUrl = value; - }, - () => { - result.errors.push('Missing value for --base-url'); - } - ); - - remaining = applyRepeatedOption( - remaining, - ['--api-key'], - (value) => { - result.apiKey = value; - }, - () => { - result.errors.push('Missing value for --api-key'); - } - ); - - remaining = applyRepeatedOption( - remaining, - ['--model'], - (value) => { - result.model = value; - }, - () => { - result.errors.push('Missing value for --model'); - } - ); - - remaining = applyRepeatedOption( - remaining, - ['--preset'], - (value) => { - result.preset = value; - }, - () => { - result.errors.push('Missing value for --preset'); - } - ); - - remaining = applyRepeatedOption( - remaining, - ['--target'], - (value) => { - const target = parseTargetValue(value); - if (!target) { - result.errors.push(`Invalid --target value "${value}". Use: claude or droid`); - return; - } - result.target = target; - }, - () => { - result.errors.push('Missing value for --target'); - } - ); - - const positionalArgs = extractPositionalArgs(remaining); - result.name = positionalArgs[0]; - return result; -} - -/** Handle 'ccs api create' command */ -async function handleCreate(args: string[]): Promise { - await initUI(); - const parsedArgs = parseApiCommandArgs(args); - - if (parsedArgs.errors.length > 0) { - parsedArgs.errors.forEach((errorMessage) => { - console.log(fail(errorMessage)); - }); - process.exit(1); - } - - console.log(header('Create API Profile')); - console.log(''); - - // Handle --preset option for quick provider setup - const preset = parsedArgs.preset ? getPresetById(parsedArgs.preset) : null; - if (parsedArgs.preset && !preset) { - console.log(fail(`Unknown preset: ${parsedArgs.preset}`)); - console.log(''); - console.log('Available presets:'); - getPresetIds().forEach((id) => console.log(` - ${sanitizeHelpText(id)}`)); - process.exit(1); - } - - // Step 1: API name (use preset default if --preset provided) - let name = parsedArgs.name || preset?.defaultProfileName; - if (!name) { - name = await InteractivePrompt.input('API name', { - validate: validateApiName, - }); - } else { - const error = validateApiName(name); - if (error) { - console.log(fail(error)); - process.exit(1); - } - } - - // Check if exists - if (apiProfileExists(name) && !parsedArgs.force) { - console.log(fail(`API '${name}' already exists`)); - console.log(` Use ${color('--force', 'command')} to overwrite`); - process.exit(1); - } - - // Step 2: Base URL (use preset if provided; skip prompt for presets with empty baseUrl like anthropic) - let baseUrl = parsedArgs.baseUrl ?? preset?.baseUrl ?? ''; - if (!baseUrl && !preset) { - baseUrl = await InteractivePrompt.input( - 'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)', - { validate: validateUrl } - ); - } else if (!preset) { - // Only validate custom URLs, not preset URLs - const error = validateUrl(baseUrl); - if (error) { - console.log(fail(error)); - process.exit(1); - } - } - - // Check for common URL mistakes and warn (skip for presets) - if (!preset) { - const urlWarning = getUrlWarning(baseUrl); - if (urlWarning) { - console.log(''); - console.log(warn(urlWarning)); - const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', { - default: false, - }); - if (!continueAnyway) { - baseUrl = await InteractivePrompt.input('API Base URL', { - validate: validateUrl, - default: sanitizeBaseUrl(baseUrl), - }); - } - } - } else { - // Show preset info - console.log(info(`Using preset: ${preset.name}`)); - console.log(dim(` ${preset.description}`)); - if (preset.baseUrl) { - console.log(dim(` Base URL: ${preset.baseUrl}`)); - } else { - console.log(dim(` Auth: Native Anthropic API (x-api-key header)`)); - } - console.log(''); - } - - // Auto-detect Anthropic direct API when user enters api.anthropic.com URL without preset - if (baseUrl && baseUrl.includes('api.anthropic.com') && !preset) { - console.log(''); - console.log(info('Anthropic Direct API detected. Base URL will be omitted for native auth.')); - baseUrl = ''; - } - - // OpenRouter detection: offer interactive model picker - let openRouterModel: string | undefined; - let openRouterTierMapping: { opus?: string; sonnet?: string; haiku?: string } | undefined; - - if (isOpenRouterUrl(baseUrl) && !parsedArgs.model) { - console.log(''); - console.log(info('OpenRouter detected!')); - - const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', { - default: true, - }); - - if (useInteractive) { - const selection = await pickOpenRouterModel(); - - if (selection) { - openRouterModel = selection.model; - openRouterTierMapping = selection.tierMapping; - } - } - - console.log(''); - console.log(dim('Note: For OpenRouter, ANTHROPIC_API_KEY should be empty.')); - } - - // Step 3: API Key (skip if preset has requiresApiKey: false) - let apiKey = parsedArgs.apiKey; - - if (preset?.requiresApiKey === false) { - const presetLabel = preset.name; - const optionalApiKey = preset.apiKeyPlaceholder || preset.id; - - if (parsedArgs.apiKey) { - console.log(dim(`Note: Using provided API key for ${presetLabel} (optional)`)); - apiKey = parsedArgs.apiKey; - } else { - console.log(info(`No API key required for ${presetLabel}`)); - // Local providers still need a truthy auth token persisted in settings.json. - apiKey = optionalApiKey; - } - } else if (!apiKey) { - const keyPrompt = preset?.apiKeyHint ? `API Key (${preset.apiKeyHint})` : 'API Key'; - apiKey = await InteractivePrompt.password(keyPrompt); - if (!apiKey) { - console.log(fail('API key is required')); - process.exit(1); - } - } - - // Step 4: Model configuration (use preset default if available) - const defaultModel = preset?.defaultModel || 'claude-sonnet-4-6'; - let model = parsedArgs.model || openRouterModel || preset?.defaultModel; - if (!model && !parsedArgs.yes && !preset) { - model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', { - default: defaultModel, - }); - } - model = model || defaultModel; - - // Step 5: Model mapping for Opus/Sonnet/Haiku (skip prompt for presets with --yes) - let opusModel = openRouterTierMapping?.opus || model; - let sonnetModel = openRouterTierMapping?.sonnet || model; - let haikuModel = openRouterTierMapping?.haiku || model; - const isCustomModel = model !== defaultModel; - const hasOpenRouterTierMapping = openRouterTierMapping !== undefined; - const hasPreset = preset !== null; - - if (!parsedArgs.yes && !hasOpenRouterTierMapping && !hasPreset) { - let wantCustomMapping = isCustomModel; - - if (!isCustomModel) { - console.log(''); - console.log(dim('Some API proxies route different model types to different backends.')); - wantCustomMapping = await InteractivePrompt.confirm( - 'Configure different models for Opus/Sonnet/Haiku?', - { default: false } - ); - } - - if (wantCustomMapping) { - console.log(''); - console.log( - dim( - isCustomModel - ? 'Configure model IDs for each tier (defaults to your model):' - : 'Leave blank to use the default model for each.' - ) - ); - opusModel = - (await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', { - default: model, - })) || model; - sonnetModel = - (await InteractivePrompt.input('Sonnet model (ANTHROPIC_DEFAULT_SONNET_MODEL)', { - default: model, - })) || model; - haikuModel = - (await InteractivePrompt.input('Haiku model (ANTHROPIC_DEFAULT_HAIKU_MODEL)', { - default: model, - })) || model; - } - } - - const models: ModelMapping = { - default: model, - opus: opusModel, - sonnet: sonnetModel, - haiku: haikuModel, - }; - let resolvedTarget: TargetType = parsedArgs.target || 'claude'; - - if (!parsedArgs.target && !parsedArgs.yes) { - const useDroidByDefault = await InteractivePrompt.confirm( - 'Set default target to Factory Droid for this profile?', - { default: false } - ); - if (useDroidByDefault) { - resolvedTarget = 'droid'; - } - } - - // Create profile - console.log(''); - console.log(info('Creating API profile...')); - - const result = createApiProfile(name, baseUrl || '', apiKey, models, resolvedTarget); - - if (!result.success) { - console.log(fail(`Failed to create API profile: ${result.error}`)); - process.exit(1); - } - - // Trigger sync to local CLIProxy config (best-effort) - try { - syncToLocalConfig(); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`); - } - - // Display success - console.log(''); - const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model; - let infoMsg = - `API: ${name}\n` + - `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + - `Settings: ${result.settingsFile}\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}\n` + - `Target: ${resolvedTarget}`; - - if (hasCustomMapping) { - infoMsg += - `\n\nModel Mapping:\n` + - ` Opus: ${opusModel}\n` + - ` Sonnet: ${sonnetModel}\n` + - ` Haiku: ${haikuModel}`; - } - - console.log(infoBox(infoMsg, 'API Profile Created')); - console.log(''); - console.log(header('Usage')); - if (resolvedTarget === 'droid') { - console.log( - ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` - ); - console.log( - ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` - ); - console.log( - ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` - ); - } else { - console.log( - ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` - ); - console.log( - ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` - ); - } - console.log(''); - console.log(header('Edit Settings')); - console.log(` ${dim('To modify env vars later:')}`); - console.log(` ${color(`nano ${result.settingsFile.replace('~', '$HOME')}`, 'command')}`); - console.log(''); -} - -/** Handle 'ccs api list' command */ -async function handleList(): Promise { - await initUI(); - - console.log(header('CCS API Profiles')); - console.log(''); - - const { profiles, variants } = listApiProfiles(); - - if (profiles.length === 0) { - console.log(warn('No API profiles configured')); - console.log(''); - console.log('To create an API profile:'); - console.log(` ${color('ccs api create', 'command')}`); - console.log(''); - return; - } - - // Build table data - const rows: string[][] = profiles.map((p) => { - const status = p.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning'); - return [p.name, p.target, p.settingsPath, status]; - }); - - const colWidths = isUsingUnifiedConfig() ? [15, 10, 20, 10] : [15, 10, 35, 10]; - console.log( - table(rows, { - head: ['API', 'Target', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'], - colWidths, - }) - ); - console.log(''); - - // Show CLIProxy variants if any - if (variants.length > 0) { - console.log(subheader('CLIProxy Variants')); - const cliproxyRows = variants.map((v) => [v.name, v.provider, v.target, v.settings]); - console.log( - table(cliproxyRows, { - head: ['Variant', 'Provider', 'Target', 'Settings'], - colWidths: [15, 12, 10, 28], - }) - ); - console.log(''); - } - - console.log(dim(`Total: ${profiles.length} API profile(s)`)); - console.log(''); -} - -/** Handle 'ccs api remove' command */ -async function handleRemove(args: string[]): Promise { - await initUI(); - const parsedArgs = parseApiCommandArgs(args); - - if (parsedArgs.errors.length > 0) { - parsedArgs.errors.forEach((errorMessage) => { - console.log(fail(errorMessage)); - }); - process.exit(1); - } - - const apis = getApiProfileNames(); - - if (apis.length === 0) { - console.log(warn('No API profiles to remove')); - process.exit(0); - } - - // Interactive API selection if not provided - let name = parsedArgs.name; - if (!name) { - console.log(header('Remove API Profile')); - console.log(''); - console.log('Available APIs:'); - apis.forEach((p, i) => console.log(` ${i + 1}. ${p}`)); - console.log(''); - - name = await InteractivePrompt.input('API name to remove', { - validate: (val) => { - if (!val) return 'API name is required'; - if (!apis.includes(val)) return `API '${val}' not found`; - return null; - }, - }); - } - - if (!apis.includes(name)) { - console.log(fail(`API '${name}' not found`)); - console.log(''); - console.log('Available APIs:'); - apis.forEach((p) => console.log(` - ${p}`)); - process.exit(1); - } - - // Confirm deletion - console.log(''); - console.log(`API '${color(name, 'command')}' will be removed.`); - console.log(` Settings: ~/.ccs/${name}.settings.json`); - if (isUsingUnifiedConfig()) { - console.log(' Config: ~/.ccs/config.yaml'); - } - console.log(''); - - const confirmed = - parsedArgs.yes || - (await InteractivePrompt.confirm('Delete this API profile?', { default: false })); - - if (!confirmed) { - console.log(info('Cancelled')); - process.exit(0); - } - - const result = removeApiProfile(name); - - if (!result.success) { - console.log(fail(`Failed to remove API profile: ${result.error}`)); - process.exit(1); - } - - console.log(ok(`API profile removed: ${name}`)); - console.log(''); -} - -/** Handle 'ccs api discover' command */ -async function handleDiscover(args: string[]): Promise { - await initUI(); - const register = hasAnyFlag(args, ['--register']); - const jsonOutput = hasAnyFlag(args, ['--json']); - const force = hasAnyFlag(args, ['--force']); - - const targetParsed = parseOptionalTargetFlag(args, [...API_KNOWN_FLAGS, '--register', '--json']); - if (targetParsed.errors.length > 0) { - targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage))); - process.exit(1); - } - - const result = discoverApiProfileOrphans(); - if (jsonOutput) { - console.log(JSON.stringify(result, null, 2)); - return; - } - - console.log(header('Discover Orphan API Profiles')); - console.log(''); - - if (result.orphans.length === 0) { - console.log(ok('No orphan settings files found.')); - console.log(''); - return; - } - - const rows = result.orphans.map((orphan) => { - const status = orphan.validation.valid ? color('[OK]', 'success') : color('[X]', 'error'); - const issueSummary = - orphan.validation.issues.length > 0 - ? orphan.validation.issues[0].message - : 'Ready to register'; - return [orphan.name, status, issueSummary]; - }); - - console.log( - table(rows, { - head: ['Profile', 'Status', 'Validation'], - colWidths: [20, 10, 64], - }) - ); - console.log(''); - - if (!register) { - console.log(info('To register discovered profiles:')); - console.log(` ${color('ccs api discover --register', 'command')}`); - console.log(''); - return; - } - - const registration = registerApiProfileOrphans({ - target: targetParsed.target || 'claude', - force, - }); - console.log(ok(`Registered: ${registration.registered.length}`)); - if (registration.skipped.length > 0) { - console.log(warn(`Skipped: ${registration.skipped.length}`)); - registration.skipped.forEach((item) => { - console.log(` - ${item.name}: ${item.reason}`); - }); - } - console.log(''); -} - -/** Handle 'ccs api copy' command */ -async function handleCopy(args: string[]): Promise { - await initUI(); - const parsedArgs = parseApiCommandArgs(args); - if (parsedArgs.errors.length > 0) { - parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); - process.exit(1); - } - - const positionals = extractPositionalArgs(args); - const source = positionals[0]; - let destination = positionals[1]; - - if (!source) { - console.log(fail('Source profile is required. Usage: ccs api copy ')); - process.exit(1); - } - - if (!destination) { - destination = await InteractivePrompt.input('Destination profile name'); - } - - if (!parsedArgs.yes) { - const confirmed = await InteractivePrompt.confirm( - `Copy profile "${source}" to "${destination}"?`, - { default: true } - ); - if (!confirmed) { - console.log(info('Cancelled')); - process.exit(0); - } - } - - const result = copyApiProfile(source, destination, { - target: parsedArgs.target, - force: parsedArgs.force, - }); - - if (!result.success) { - console.log(fail(result.error || 'Failed to copy profile')); - process.exit(1); - } - - console.log(ok(`Profile copied: ${source} -> ${destination}`)); - if (result.warnings && result.warnings.length > 0) { - result.warnings.forEach((warningMessage) => console.log(warn(warningMessage))); - } - console.log(''); -} - -/** Handle 'ccs api export' command */ -async function handleExport(args: string[]): Promise { - await initUI(); - const includeSecrets = hasAnyFlag(args, ['--include-secrets']); - - const outExtracted = extractOption(args, ['--out'], { - allowDashValue: true, - knownFlags: [...API_KNOWN_FLAGS, '--out', '--include-secrets'], - }); - if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) { - console.log(fail('Missing value for --out')); - process.exit(1); - } - const outPath = outExtracted.value; - const positionals = extractPositionalArgs(outExtracted.remainingArgs); - const name = positionals[0]; - - if (!name) { - console.log(fail('Profile name is required. Usage: ccs api export [--out ]')); - process.exit(1); - } - - const result = exportApiProfile(name, includeSecrets); - if (!result.success || !result.bundle) { - console.log(fail(result.error || 'Failed to export profile')); - process.exit(1); - } - - const resolvedOutputPath = path.resolve(outPath || `${name}.ccs-profile.json`); - fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); - fs.writeFileSync(resolvedOutputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8'); - - console.log(ok(`Profile exported to: ${resolvedOutputPath}`)); - if (result.redacted) { - console.log(warn('Token was redacted in export. Use --include-secrets to include it.')); - } - console.log(''); -} - -/** Handle 'ccs api import' command */ -async function handleImport(args: string[]): Promise { - await initUI(); - const force = hasAnyFlag(args, ['--force']); - const yes = hasAnyFlag(args, ['--yes', '-y']); - - const nameExtracted = extractOption(args, ['--name'], { - allowDashValue: true, - knownFlags: [...API_KNOWN_FLAGS, '--name'], - }); - if (nameExtracted.found && (nameExtracted.missingValue || !nameExtracted.value)) { - console.log(fail('Missing value for --name')); - process.exit(1); - } - - const targetParsed = parseOptionalTargetFlag(nameExtracted.remainingArgs, [ - ...API_KNOWN_FLAGS, - '--name', - ]); - if (targetParsed.errors.length > 0) { - targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage))); - process.exit(1); - } - - const positionals = extractPositionalArgs(targetParsed.remainingArgs); - const importPath = positionals[0]; - if (!importPath) { - console.log( - fail('Import file path is required. Usage: ccs api import [--name ]') - ); - process.exit(1); - } - - if (!fs.existsSync(importPath)) { - console.log(fail(`File not found: ${importPath}`)); - process.exit(1); - } - - const raw = fs.readFileSync(importPath, 'utf8'); - let bundle: unknown; - try { - bundle = JSON.parse(raw); - } catch (error) { - console.log(fail(`Invalid JSON file: ${(error as Error).message}`)); - process.exit(1); - } - - if (!yes) { - const confirmed = await InteractivePrompt.confirm( - `Import profile bundle from "${importPath}"?`, - { - default: true, - } - ); - if (!confirmed) { - console.log(info('Cancelled')); - process.exit(0); - } - } - - const result = importApiProfileBundle(bundle, { - name: nameExtracted.value, - target: targetParsed.target, - force, - }); - - if (!result.success) { - console.log(fail(result.error || 'Failed to import profile')); - if (result.validation?.issues?.length) { - console.log(''); - result.validation.issues.forEach((issue) => { - const indicator = issue.level === 'error' ? color('[X]', 'error') : color('[!]', 'warning'); - console.log(`${indicator} ${issue.message}`); - }); - } - process.exit(1); - } - - console.log(ok(`Profile imported: ${result.name}`)); - if (result.warnings && result.warnings.length > 0) { - result.warnings.forEach((warningMessage) => console.log(warn(warningMessage))); - } - console.log(''); -} - -/** Show help for api commands */ -async function showHelp(): Promise { - await initUI(); - const presetIds = getPresetIds() - .map((id) => sanitizeHelpText(id)) - .filter(Boolean); - const presetAliases = getPresetAliases(); - const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2; - - console.log(header('CCS API Management')); - console.log(''); - console.log(subheader('Usage')); - console.log(` ${color('ccs api', 'command')} [options]`); - console.log(''); - console.log(subheader('Commands')); - console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`); - console.log(` ${color('list', 'command')} List all API profiles`); - console.log( - ` ${color('discover', 'command')} Discover orphan *.settings.json and register` - ); - console.log(` ${color('copy ', 'command')} Duplicate API profile settings + config`); - console.log( - ` ${color('export ', 'command')} Export profile bundle for cross-device transfer` - ); - console.log( - ` ${color('import ', 'command')} Import profile bundle and register profile` - ); - console.log(` ${color('remove ', 'command')} Remove an API profile`); - console.log(''); - console.log(subheader('Options')); - console.log( - ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` - ); - console.log(` ${color('--base-url ', 'command')} API base URL (create)`); - console.log(` ${color('--api-key ', 'command')} API key (create)`); - console.log(` ${color('--model ', 'command')} Default model (create)`); - console.log( - ` ${color('--target ', 'command')} Default target: claude or droid (create)` - ); - console.log(` ${color('--register', 'command')} Register discovered orphan settings`); - console.log(` ${color('--json', 'command')} JSON output for discover command`); - console.log(` ${color('--out ', 'command')} Export bundle output path`); - console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`); - console.log(` ${color('--name ', 'command')} Override profile name during import`); - console.log( - ` ${color('--force', 'command')} Overwrite existing or bypass validation (create/discover/copy/import)` - ); - console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); - console.log(''); - console.log(subheader('Provider Presets')); - PROVIDER_PRESETS.forEach((preset) => console.log(renderPresetHelpLine(preset, presetIdWidth))); - Object.entries(presetAliases).forEach(([alias, canonical]) => { - const safeAlias = sanitizeHelpText(alias); - const safeCanonical = sanitizeHelpText(canonical); - console.log( - ` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}` - ); - }); - console.log(''); - console.log(subheader('Examples')); - console.log(` ${dim('# Interactive wizard')}`); - console.log(` ${color('ccs api create', 'command')}`); - console.log(''); - console.log(` ${dim('# Quick setup with preset')}`); - console.log(` ${color('ccs api create --preset anthropic', 'command')}`); - console.log(` ${color('ccs api create --preset openrouter', 'command')}`); - console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); - console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); - console.log(` ${color('ccs api create --preset glm', 'command')}`); - console.log(''); - console.log(` ${dim('# Create with name')}`); - console.log(` ${color('ccs api create myapi', 'command')}`); - console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); - console.log(''); - console.log(` ${dim('# Remove API profile')}`); - console.log(` ${color('ccs api remove myapi', 'command')}`); - console.log(''); - console.log(` ${dim('# Discover and register orphan settings files')}`); - console.log(` ${color('ccs api discover', 'command')}`); - console.log(` ${color('ccs api discover --register', 'command')}`); - console.log(''); - console.log(` ${dim('# Duplicate an existing API profile')}`); - console.log(` ${color('ccs api copy glm glm-backup', 'command')}`); - console.log(''); - console.log(` ${dim('# Export and import across devices')}`); - console.log(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`); - console.log(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`); - console.log(''); - console.log(` ${dim('# Show all API profiles')}`); - console.log(` ${color('ccs api list', 'command')}`); - console.log(''); -} - -/** Main api command router */ -export async function handleApiCommand(args: string[]): Promise { - const command = args[0]; - - if (!command || command === '--help' || command === '-h' || command === 'help') { - await showHelp(); - return; - } - - switch (command) { - case 'create': - await handleCreate(args.slice(1)); - break; - case 'list': - await handleList(); - break; - case 'discover': - await handleDiscover(args.slice(1)); - break; - case 'copy': - await handleCopy(args.slice(1)); - break; - case 'export': - await handleExport(args.slice(1)); - break; - case 'import': - await handleImport(args.slice(1)); - break; - case 'remove': - case 'delete': - case 'rm': - await handleRemove(args.slice(1)); - break; - default: - await initUI(); - console.log(fail(`Unknown command: ${command}`)); - console.log(''); - console.log('Run for help:'); - console.log(` ${color('ccs api --help', 'command')}`); - process.exit(1); - } -} diff --git a/src/commands/api-command/copy-command.ts b/src/commands/api-command/copy-command.ts new file mode 100644 index 00000000..512d56ab --- /dev/null +++ b/src/commands/api-command/copy-command.ts @@ -0,0 +1,47 @@ +import { copyApiProfile } from '../../api/services'; +import { fail, info, initUI, ok, warn } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import { exitOnApiCommandErrors, extractPositionalArgs, parseApiCommandArgs } from './shared'; + +export async function handleApiCopyCommand(args: string[]): Promise { + await initUI(); + const parsedArgs = parseApiCommandArgs(args); + exitOnApiCommandErrors(parsedArgs.errors); + + const positionals = extractPositionalArgs(args); + const source = positionals[0]; + let destination = positionals[1]; + + if (!source) { + console.log(fail('Source profile is required. Usage: ccs api copy ')); + process.exit(1); + } + + if (!destination) { + destination = await InteractivePrompt.input('Destination profile name'); + } + + if (!parsedArgs.yes) { + const confirmed = await InteractivePrompt.confirm( + `Copy profile "${source}" to "${destination}"?`, + { default: true } + ); + if (!confirmed) { + console.log(info('Cancelled')); + process.exit(0); + } + } + + const result = copyApiProfile(source, destination, { + target: parsedArgs.target, + force: parsedArgs.force, + }); + if (!result.success) { + console.log(fail(result.error || 'Failed to copy profile')); + process.exit(1); + } + + console.log(ok(`Profile copied: ${source} -> ${destination}`)); + result.warnings?.forEach((warningMessage) => console.log(warn(warningMessage))); + console.log(''); +} diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts new file mode 100644 index 00000000..cf526560 --- /dev/null +++ b/src/commands/api-command/create-command.ts @@ -0,0 +1,325 @@ +import { + apiProfileExists, + createApiProfile, + getPresetById, + getPresetIds, + getUrlWarning, + isOpenRouterUrl, + isUsingUnifiedConfig, + pickOpenRouterModel, + sanitizeBaseUrl, + validateApiName, + validateUrl, + type ModelMapping, + type ProviderPreset, +} from '../../api/services'; +import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync'; +import type { TargetType } from '../../targets/target-adapter'; +import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared'; + +function resolvePresetOrExit(presetId?: string): ProviderPreset | null { + if (!presetId) { + return null; + } + + const preset = getPresetById(presetId); + if (preset) { + return preset; + } + + console.log(fail(`Unknown preset: ${presetId}`)); + console.log(''); + console.log('Available presets:'); + getPresetIds().forEach((id) => console.log(` - ${id}`)); + process.exit(1); +} + +async function resolveProfileName( + providedName: string | undefined, + preset: ProviderPreset | null +): Promise { + const name = providedName || preset?.defaultProfileName; + if (!name) { + return InteractivePrompt.input('API name', { + validate: validateApiName, + }); + } + + const error = validateApiName(name); + if (error) { + console.log(fail(error)); + process.exit(1); + } + return name; +} + +async function resolveBaseUrl( + providedBaseUrl: string | undefined, + preset: ProviderPreset | null +): Promise { + let baseUrl = providedBaseUrl ?? preset?.baseUrl ?? ''; + + if (!baseUrl && !preset) { + baseUrl = await InteractivePrompt.input( + 'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)', + { validate: validateUrl } + ); + } else if (!preset) { + const error = validateUrl(baseUrl); + if (error) { + console.log(fail(error)); + process.exit(1); + } + } + + if (!preset) { + const urlWarning = getUrlWarning(baseUrl); + if (urlWarning) { + console.log(''); + console.log(warn(urlWarning)); + const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', { + default: false, + }); + if (!continueAnyway) { + baseUrl = await InteractivePrompt.input('API Base URL', { + validate: validateUrl, + default: sanitizeBaseUrl(baseUrl), + }); + } + } + return baseUrl; + } + + console.log(info(`Using preset: ${preset.name}`)); + console.log(dim(` ${preset.description}`)); + console.log( + dim( + preset.baseUrl + ? ` Base URL: ${preset.baseUrl}` + : ' Auth: Native Anthropic API (x-api-key header)' + ) + ); + console.log(''); + return baseUrl; +} + +async function resolveApiKey( + providedApiKey: string | undefined, + preset: ProviderPreset | null +): Promise { + if (preset?.requiresApiKey === false) { + if (providedApiKey) { + console.log(dim(`Note: Using provided API key for ${preset.name} (optional)`)); + return providedApiKey; + } + console.log(info(`No API key required for ${preset.name}`)); + return preset.apiKeyPlaceholder || preset.id; + } + + if (providedApiKey) { + return providedApiKey; + } + + const keyPrompt = preset?.apiKeyHint ? `API Key (${preset.apiKeyHint})` : 'API Key'; + const apiKey = await InteractivePrompt.password(keyPrompt); + if (!apiKey) { + console.log(fail('API key is required')); + process.exit(1); + } + return apiKey; +} + +async function resolveModelConfiguration( + baseUrl: string, + preset: ProviderPreset | null, + providedModel: string | undefined, + yes: boolean | undefined +): Promise<{ model: string; models: ModelMapping }> { + let openRouterModel: string | undefined; + let openRouterTierMapping: { opus?: string; sonnet?: string; haiku?: string } | undefined; + + if (isOpenRouterUrl(baseUrl) && !providedModel) { + console.log(''); + console.log(info('OpenRouter detected!')); + const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', { + default: true, + }); + if (useInteractive) { + const selection = await pickOpenRouterModel(); + if (selection) { + openRouterModel = selection.model; + openRouterTierMapping = selection.tierMapping; + } + } + console.log(''); + console.log(dim('Note: For OpenRouter, ANTHROPIC_API_KEY should be empty.')); + } + + const defaultModel = preset?.defaultModel || 'claude-sonnet-4-6'; + let model = providedModel || openRouterModel || preset?.defaultModel; + if (!model && !yes && !preset) { + model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', { + default: defaultModel, + }); + } + model = model || defaultModel; + + let opusModel = openRouterTierMapping?.opus || model; + let sonnetModel = openRouterTierMapping?.sonnet || model; + let haikuModel = openRouterTierMapping?.haiku || model; + const shouldPromptForMapping = !yes && !openRouterTierMapping && !preset; + + if (shouldPromptForMapping) { + let wantCustomMapping = model !== defaultModel; + if (!wantCustomMapping) { + console.log(''); + console.log(dim('Some API proxies route different model types to different backends.')); + wantCustomMapping = await InteractivePrompt.confirm( + 'Configure different models for Opus/Sonnet/Haiku?', + { default: false } + ); + } + + if (wantCustomMapping) { + console.log(''); + console.log(dim('Leave blank to use the default model for each tier.')); + opusModel = + (await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', { + default: model, + })) || model; + sonnetModel = + (await InteractivePrompt.input('Sonnet model (ANTHROPIC_DEFAULT_SONNET_MODEL)', { + default: model, + })) || model; + haikuModel = + (await InteractivePrompt.input('Haiku model (ANTHROPIC_DEFAULT_HAIKU_MODEL)', { + default: model, + })) || model; + } + } + + return { + model, + models: { + default: model, + opus: opusModel, + sonnet: sonnetModel, + haiku: haikuModel, + }, + }; +} + +async function resolveDefaultTarget( + providedTarget: TargetType | undefined, + yes: boolean | undefined +): Promise { + if (providedTarget) { + return providedTarget; + } + if (yes) { + return 'claude'; + } + + const useDroidByDefault = await InteractivePrompt.confirm( + 'Set default target to Factory Droid for this profile?', + { default: false } + ); + return useDroidByDefault ? 'droid' : 'claude'; +} + +export async function handleApiCreateCommand(args: string[]): Promise { + await initUI(); + const parsedArgs = parseApiCommandArgs(args); + exitOnApiCommandErrors(parsedArgs.errors); + + console.log(header('Create API Profile')); + console.log(''); + + const preset = resolvePresetOrExit(parsedArgs.preset); + const name = await resolveProfileName(parsedArgs.name, preset); + + if (apiProfileExists(name) && !parsedArgs.force) { + console.log(fail(`API '${name}' already exists`)); + console.log(` Use ${color('--force', 'command')} to overwrite`); + process.exit(1); + } + + let baseUrl = await resolveBaseUrl(parsedArgs.baseUrl, preset); + if (baseUrl && baseUrl.includes('api.anthropic.com') && !preset) { + console.log(''); + console.log(info('Anthropic Direct API detected. Base URL will be omitted for native auth.')); + baseUrl = ''; + } + + const apiKey = await resolveApiKey(parsedArgs.apiKey, preset); + const { model, models } = await resolveModelConfiguration( + baseUrl, + preset, + parsedArgs.model, + parsedArgs.yes + ); + const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + + console.log(''); + console.log(info('Creating API profile...')); + const result = createApiProfile(name, baseUrl || '', apiKey, models, target); + if (!result.success) { + console.log(fail(`Failed to create API profile: ${result.error}`)); + process.exit(1); + } + + try { + syncToLocalConfig(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`); + } + + const hasCustomMapping = + models.opus !== model || models.sonnet !== model || models.haiku !== model; + let details = + `API: ${name}\n` + + `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + + `Settings: ${result.settingsFile}\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}\n` + + `Target: ${target}`; + + if (hasCustomMapping) { + details += + `\n\nModel Mapping:\n` + + ` Opus: ${models.opus}\n` + + ` Sonnet: ${models.sonnet}\n` + + ` Haiku: ${models.haiku}`; + } + + console.log(''); + console.log(infoBox(details, 'API Profile Created')); + console.log(''); + console.log(header('Usage')); + if (target === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } + console.log(''); + console.log(header('Edit Settings')); + console.log(` ${dim('To modify env vars later:')}`); + console.log(` ${color(`nano ${result.settingsFile.replace('~', '$HOME')}`, 'command')}`); + console.log(''); +} diff --git a/src/commands/api-command/discover-command.ts b/src/commands/api-command/discover-command.ts new file mode 100644 index 00000000..2d6cdb9a --- /dev/null +++ b/src/commands/api-command/discover-command.ts @@ -0,0 +1,70 @@ +import { discoverApiProfileOrphans, registerApiProfileOrphans } from '../../api/services'; +import { color, fail, header, info, initUI, ok, table, warn } from '../../utils/ui'; +import { hasAnyFlag } from '../arg-extractor'; +import { API_KNOWN_FLAGS, parseOptionalTargetFlag } from './shared'; + +export async function handleApiDiscoverCommand(args: string[]): Promise { + await initUI(); + const register = hasAnyFlag(args, ['--register']); + const jsonOutput = hasAnyFlag(args, ['--json']); + const force = hasAnyFlag(args, ['--force']); + + const targetParsed = parseOptionalTargetFlag(args, [...API_KNOWN_FLAGS, '--register', '--json']); + if (targetParsed.errors.length > 0) { + targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exit(1); + } + + const result = discoverApiProfileOrphans(); + if (jsonOutput) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(header('Discover Orphan API Profiles')); + console.log(''); + + if (result.orphans.length === 0) { + console.log(ok('No orphan settings files found.')); + console.log(''); + return; + } + + console.log( + table( + result.orphans.map((orphan) => { + const status = orphan.validation.valid ? color('[OK]', 'success') : color('[X]', 'error'); + const issueSummary = + orphan.validation.issues.length > 0 + ? orphan.validation.issues[0].message + : 'Ready to register'; + return [orphan.name, status, issueSummary]; + }), + { + head: ['Profile', 'Status', 'Validation'], + colWidths: [20, 10, 64], + } + ) + ); + console.log(''); + + if (!register) { + console.log(info('To register discovered profiles:')); + console.log(` ${color('ccs api discover --register', 'command')}`); + console.log(''); + return; + } + + const registration = registerApiProfileOrphans({ + target: targetParsed.target || 'claude', + force, + }); + console.log(ok(`Registered: ${registration.registered.length}`)); + if (registration.skipped.length > 0) { + console.log(warn(`Skipped: ${registration.skipped.length}`)); + registration.skipped.forEach((item) => { + console.log(` - ${item.name}: ${item.reason}`); + }); + } + console.log(''); +} diff --git a/src/commands/api-command/export-command.ts b/src/commands/api-command/export-command.ts new file mode 100644 index 00000000..35fd518e --- /dev/null +++ b/src/commands/api-command/export-command.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { exportApiProfile } from '../../api/services'; +import { fail, initUI, ok, warn } from '../../utils/ui'; +import { extractOption, hasAnyFlag } from '../arg-extractor'; +import { API_KNOWN_FLAGS, extractPositionalArgs } from './shared'; + +export async function handleApiExportCommand(args: string[]): Promise { + await initUI(); + const includeSecrets = hasAnyFlag(args, ['--include-secrets']); + + const outExtracted = extractOption(args, ['--out'], { + allowDashValue: true, + knownFlags: [...API_KNOWN_FLAGS, '--out', '--include-secrets'], + }); + if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) { + console.log(fail('Missing value for --out')); + process.exit(1); + } + + const name = extractPositionalArgs(outExtracted.remainingArgs)[0]; + if (!name) { + console.log(fail('Profile name is required. Usage: ccs api export [--out ]')); + process.exit(1); + } + + const result = exportApiProfile(name, includeSecrets); + if (!result.success || !result.bundle) { + console.log(fail(result.error || 'Failed to export profile')); + process.exit(1); + } + + const outputPath = path.resolve(outExtracted.value || `${name}.ccs-profile.json`); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8'); + + console.log(ok(`Profile exported to: ${outputPath}`)); + if (result.redacted) { + console.log(warn('Token was redacted in export. Use --include-secrets to include it.')); + } + console.log(''); +} diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts new file mode 100644 index 00000000..e9c5f26a --- /dev/null +++ b/src/commands/api-command/help.ts @@ -0,0 +1,117 @@ +import { + PROVIDER_PRESETS, + getPresetAliases, + getPresetIds, + type ProviderPreset, +} from '../../api/services'; +import { color, dim, fail, header, initUI, subheader } from '../../utils/ui'; +import { sanitizeHelpText } from './shared'; + +function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { + const presetId = sanitizeHelpText(preset.id) || 'unknown'; + const paddedId = presetId.padEnd(idWidth); + const presetName = sanitizeHelpText(preset.name) || 'Unknown preset'; + const presetDescription = sanitizeHelpText(preset.description) || 'No description'; + return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`; +} + +export async function showApiCommandHelp(): Promise { + await initUI(); + const presetIds = getPresetIds() + .map((id) => sanitizeHelpText(id)) + .filter(Boolean); + const presetAliases = getPresetAliases(); + const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2; + + console.log(header('CCS API Management')); + console.log(''); + console.log(subheader('Usage')); + console.log(` ${color('ccs api', 'command')} [options]`); + console.log(''); + console.log(subheader('Commands')); + console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`); + console.log(` ${color('list', 'command')} List all API profiles`); + console.log( + ` ${color('discover', 'command')} Discover orphan *.settings.json and register` + ); + console.log(` ${color('copy ', 'command')} Duplicate API profile settings + config`); + console.log( + ` ${color('export ', 'command')} Export profile bundle for cross-device transfer` + ); + console.log( + ` ${color('import ', 'command')} Import profile bundle and register profile` + ); + console.log(` ${color('remove ', 'command')} Remove an API profile`); + console.log(''); + console.log(subheader('Options')); + console.log( + ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` + ); + console.log(` ${color('--base-url ', 'command')} API base URL (create)`); + console.log(` ${color('--api-key ', 'command')} API key (create)`); + console.log(` ${color('--model ', 'command')} Default model (create)`); + console.log( + ` ${color('--target ', 'command')} Default target: claude or droid (create)` + ); + console.log(` ${color('--register', 'command')} Register discovered orphan settings`); + console.log(` ${color('--json', 'command')} JSON output for discover command`); + console.log(` ${color('--out ', 'command')} Export bundle output path`); + console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`); + console.log(` ${color('--name ', 'command')} Override profile name during import`); + console.log( + ` ${color('--force', 'command')} Overwrite existing or bypass validation (create/discover/copy/import)` + ); + console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); + console.log(''); + console.log(subheader('Provider Presets')); + PROVIDER_PRESETS.forEach((preset) => console.log(renderPresetHelpLine(preset, presetIdWidth))); + Object.entries(presetAliases).forEach(([alias, canonical]) => { + const safeAlias = sanitizeHelpText(alias); + const safeCanonical = sanitizeHelpText(canonical); + console.log( + ` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}` + ); + }); + console.log(''); + console.log(subheader('Examples')); + console.log(` ${dim('# Interactive wizard')}`); + console.log(` ${color('ccs api create', 'command')}`); + console.log(''); + console.log(` ${dim('# Quick setup with preset')}`); + console.log(` ${color('ccs api create --preset anthropic', 'command')}`); + console.log(` ${color('ccs api create --preset openrouter', 'command')}`); + console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); + console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); + console.log(` ${color('ccs api create --preset glm', 'command')}`); + console.log(''); + console.log(` ${dim('# Create with name')}`); + console.log(` ${color('ccs api create myapi', 'command')}`); + console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); + console.log(''); + console.log(` ${dim('# Remove API profile')}`); + console.log(` ${color('ccs api remove myapi', 'command')}`); + console.log(''); + console.log(` ${dim('# Discover and register orphan settings files')}`); + console.log(` ${color('ccs api discover', 'command')}`); + console.log(` ${color('ccs api discover --register', 'command')}`); + console.log(''); + console.log(` ${dim('# Duplicate an existing API profile')}`); + console.log(` ${color('ccs api copy glm glm-backup', 'command')}`); + console.log(''); + console.log(` ${dim('# Export and import across devices')}`); + console.log(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`); + console.log(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`); + console.log(''); + console.log(` ${dim('# Show all API profiles')}`); + console.log(` ${color('ccs api list', 'command')}`); + console.log(''); +} + +export async function showUnknownApiCommand(command: string): Promise { + await initUI(); + console.log(fail(`Unknown command: ${command}`)); + console.log(''); + console.log('Run for help:'); + console.log(` ${color('ccs api --help', 'command')}`); + process.exit(1); +} diff --git a/src/commands/api-command/import-command.ts b/src/commands/api-command/import-command.ts new file mode 100644 index 00000000..ed319cdc --- /dev/null +++ b/src/commands/api-command/import-command.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import { importApiProfileBundle, type ProfileValidationIssue } from '../../api/services'; +import { color, fail, info, initUI, ok, warn } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import { extractOption, hasAnyFlag } from '../arg-extractor'; +import { API_KNOWN_FLAGS, extractPositionalArgs, parseOptionalTargetFlag } from './shared'; + +function renderValidationIssue(issue: ProfileValidationIssue): void { + const indicator = issue.level === 'error' ? color('[X]', 'error') : color('[!]', 'warning'); + console.log(`${indicator} ${issue.message}`); +} + +export async function handleApiImportCommand(args: string[]): Promise { + await initUI(); + const force = hasAnyFlag(args, ['--force']); + const yes = hasAnyFlag(args, ['--yes', '-y']); + + const nameExtracted = extractOption(args, ['--name'], { + allowDashValue: true, + knownFlags: [...API_KNOWN_FLAGS, '--name'], + }); + if (nameExtracted.found && (nameExtracted.missingValue || !nameExtracted.value)) { + console.log(fail('Missing value for --name')); + process.exit(1); + } + + const targetParsed = parseOptionalTargetFlag(nameExtracted.remainingArgs, [ + ...API_KNOWN_FLAGS, + '--name', + ]); + if (targetParsed.errors.length > 0) { + targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exit(1); + } + + const importPath = extractPositionalArgs(targetParsed.remainingArgs)[0]; + if (!importPath) { + console.log( + fail('Import file path is required. Usage: ccs api import [--name ]') + ); + process.exit(1); + } + + if (!fs.existsSync(importPath)) { + console.log(fail(`File not found: ${importPath}`)); + process.exit(1); + } + + let bundle: unknown; + try { + bundle = JSON.parse(fs.readFileSync(importPath, 'utf8')); + } catch (error) { + console.log(fail(`Invalid JSON file: ${(error as Error).message}`)); + process.exit(1); + } + + if (!yes) { + const confirmed = await InteractivePrompt.confirm( + `Import profile bundle from "${importPath}"?`, + { + default: true, + } + ); + if (!confirmed) { + console.log(info('Cancelled')); + process.exit(0); + } + } + + const result = importApiProfileBundle(bundle, { + name: nameExtracted.value, + target: targetParsed.target, + force, + }); + if (!result.success) { + console.log(fail(result.error || 'Failed to import profile')); + if (result.validation?.issues?.length) { + console.log(''); + result.validation.issues.forEach(renderValidationIssue); + } + process.exit(1); + } + + console.log(ok(`Profile imported: ${result.name}`)); + result.warnings?.forEach((warningMessage) => console.log(warn(warningMessage))); + console.log(''); +} diff --git a/src/commands/api-command/index.ts b/src/commands/api-command/index.ts new file mode 100644 index 00000000..43bd0ec5 --- /dev/null +++ b/src/commands/api-command/index.ts @@ -0,0 +1,31 @@ +import { dispatchNamedCommand, type NamedCommandRoute } from '../named-command-router'; +import { handleApiCopyCommand } from './copy-command'; +import { handleApiCreateCommand } from './create-command'; +import { handleApiDiscoverCommand } from './discover-command'; +import { handleApiExportCommand } from './export-command'; +import { showApiCommandHelp, showUnknownApiCommand } from './help'; +import { handleApiImportCommand } from './import-command'; +import { handleApiListCommand } from './list-command'; +import { handleApiRemoveCommand } from './remove-command'; + +export { parseApiCommandArgs } from './shared'; + +const API_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ + { name: 'create', handle: handleApiCreateCommand }, + { name: 'list', handle: async () => handleApiListCommand() }, + { name: 'discover', handle: handleApiDiscoverCommand }, + { name: 'copy', handle: handleApiCopyCommand }, + { name: 'export', handle: handleApiExportCommand }, + { name: 'import', handle: handleApiImportCommand }, + { name: 'remove', aliases: ['delete', 'rm'], handle: handleApiRemoveCommand }, +]; + +export async function handleApiCommand(args: string[]): Promise { + await dispatchNamedCommand({ + args, + routes: API_COMMAND_ROUTES, + onHelp: showApiCommandHelp, + onUnknown: showUnknownApiCommand, + allowEmptyHelp: true, + }); +} diff --git a/src/commands/api-command/list-command.ts b/src/commands/api-command/list-command.ts new file mode 100644 index 00000000..c308c410 --- /dev/null +++ b/src/commands/api-command/list-command.ts @@ -0,0 +1,53 @@ +import { listApiProfiles, isUsingUnifiedConfig } from '../../api/services'; +import { dim, header, initUI, subheader, table, warn, color } from '../../utils/ui'; + +export async function handleApiListCommand(): Promise { + await initUI(); + console.log(header('CCS API Profiles')); + console.log(''); + + const { profiles, variants } = listApiProfiles(); + if (profiles.length === 0) { + console.log(warn('No API profiles configured')); + console.log(''); + console.log('To create an API profile:'); + console.log(` ${color('ccs api create', 'command')}`); + console.log(''); + return; + } + + const rows = profiles.map((profile) => { + const status = profile.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning'); + return [profile.name, profile.target, profile.settingsPath, status]; + }); + + console.log( + table(rows, { + head: ['API', 'Target', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'], + colWidths: isUsingUnifiedConfig() ? [15, 10, 20, 10] : [15, 10, 35, 10], + }) + ); + console.log(''); + + if (variants.length > 0) { + console.log(subheader('CLIProxy Variants')); + console.log( + table( + variants.map((variant) => [ + variant.name, + variant.provider, + variant.target, + variant.settings, + ]), + { + head: ['Variant', 'Provider', 'Target', 'Settings'], + colWidths: [15, 12, 10, 28], + } + ) + ); + console.log(''); + } + + console.log(dim(`Total: ${profiles.length} API profile(s)`)); + console.log(''); +} diff --git a/src/commands/api-command/remove-command.ts b/src/commands/api-command/remove-command.ts new file mode 100644 index 00000000..73b1e6ff --- /dev/null +++ b/src/commands/api-command/remove-command.ts @@ -0,0 +1,65 @@ +import { getApiProfileNames, isUsingUnifiedConfig, removeApiProfile } from '../../api/services'; +import { color, fail, header, info, initUI, ok, warn } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared'; + +export async function handleApiRemoveCommand(args: string[]): Promise { + await initUI(); + const parsedArgs = parseApiCommandArgs(args); + exitOnApiCommandErrors(parsedArgs.errors); + + const apis = getApiProfileNames(); + if (apis.length === 0) { + console.log(warn('No API profiles to remove')); + process.exit(0); + } + + let name = parsedArgs.name; + if (!name) { + console.log(header('Remove API Profile')); + console.log(''); + console.log('Available APIs:'); + apis.forEach((api, index) => console.log(` ${index + 1}. ${api}`)); + console.log(''); + name = await InteractivePrompt.input('API name to remove', { + validate: (value) => { + if (!value) return 'API name is required'; + if (!apis.includes(value)) return `API '${value}' not found`; + return null; + }, + }); + } + + if (!apis.includes(name)) { + console.log(fail(`API '${name}' not found`)); + console.log(''); + console.log('Available APIs:'); + apis.forEach((api) => console.log(` - ${api}`)); + process.exit(1); + } + + console.log(''); + console.log(`API '${color(name, 'command')}' will be removed.`); + console.log(` Settings: ~/.ccs/${name}.settings.json`); + if (isUsingUnifiedConfig()) { + console.log(' Config: ~/.ccs/config.yaml'); + } + console.log(''); + + const confirmed = + parsedArgs.yes || + (await InteractivePrompt.confirm('Delete this API profile?', { default: false })); + if (!confirmed) { + console.log(info('Cancelled')); + process.exit(0); + } + + const result = removeApiProfile(name); + if (!result.success) { + console.log(fail(`Failed to remove API profile: ${result.error}`)); + process.exit(1); + } + + console.log(ok(`API profile removed: ${name}`)); + console.log(''); +} diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts new file mode 100644 index 00000000..0fffe582 --- /dev/null +++ b/src/commands/api-command/shared.ts @@ -0,0 +1,206 @@ +import type { TargetType } from '../../targets/target-adapter'; +import { fail } from '../../utils/ui'; +import { extractOption, hasAnyFlag } from '../arg-extractor'; + +export interface ApiCommandArgs { + name?: string; + baseUrl?: string; + apiKey?: string; + model?: string; + preset?: string; + target?: TargetType; + force?: boolean; + yes?: boolean; + errors: string[]; +} + +export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; +export const API_VALUE_FLAGS = [ + '--base-url', + '--api-key', + '--model', + '--preset', + '--target', +] as const; +export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; + +const API_VALUE_FLAG_SET = new Set(API_VALUE_FLAGS); + +export function sanitizeHelpText(value: string): string { + return value + .replace(/[\r\n\t]+/g, ' ') + .replace(/[\x00-\x1f\x7f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function applyRepeatedOption( + args: string[], + flags: readonly string[], + onValue: (value: string) => void, + onMissing: () => void +): string[] { + let remaining = [...args]; + + while (true) { + const extracted = extractOption(remaining, flags, { + allowDashValue: true, + knownFlags: API_KNOWN_FLAGS, + }); + if (!extracted.found) { + return remaining; + } + + if (extracted.missingValue || !extracted.value) { + onMissing(); + } else { + onValue(extracted.value); + } + + remaining = extracted.remainingArgs; + } +} + +export function extractPositionalArgs(args: string[]): string[] { + const positionals: string[] = []; + + for (let i = 0; i < args.length; i++) { + const token = args[i]; + if (token === '--') { + positionals.push(...args.slice(i + 1)); + break; + } + + if (token.startsWith('-')) { + if (!token.includes('=') && API_VALUE_FLAG_SET.has(token)) { + const next = args[i + 1]; + if (next && !next.startsWith('-')) { + i++; + } + } + continue; + } + + positionals.push(token); + } + + return positionals; +} + +function parseTargetValue(value: string): TargetType | null { + const normalized = value.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + return null; +} + +export function parseOptionalTargetFlag( + args: string[], + knownFlags: readonly string[] +): { target?: TargetType; remainingArgs: string[]; errors: string[] } { + const extracted = extractOption(args, ['--target'], { + allowDashValue: true, + knownFlags, + }); + if (!extracted.found) { + return { remainingArgs: args, errors: [] }; + } + if (extracted.missingValue || !extracted.value) { + return { remainingArgs: extracted.remainingArgs, errors: ['Missing value for --target'] }; + } + + const target = parseTargetValue(extracted.value); + if (!target) { + return { + remainingArgs: extracted.remainingArgs, + errors: [`Invalid --target value "${extracted.value}". Use: claude or droid`], + }; + } + + return { target, remainingArgs: extracted.remainingArgs, errors: [] }; +} + +export function parseApiCommandArgs(args: string[]): ApiCommandArgs { + const result: ApiCommandArgs = { + force: hasAnyFlag(args, ['--force']), + yes: hasAnyFlag(args, ['--yes', '-y']), + errors: [], + }; + + let remaining = [...args]; + + remaining = applyRepeatedOption( + remaining, + ['--base-url'], + (value) => { + result.baseUrl = value; + }, + () => { + result.errors.push('Missing value for --base-url'); + } + ); + + remaining = applyRepeatedOption( + remaining, + ['--api-key'], + (value) => { + result.apiKey = value; + }, + () => { + result.errors.push('Missing value for --api-key'); + } + ); + + remaining = applyRepeatedOption( + remaining, + ['--model'], + (value) => { + result.model = value; + }, + () => { + result.errors.push('Missing value for --model'); + } + ); + + remaining = applyRepeatedOption( + remaining, + ['--preset'], + (value) => { + result.preset = value; + }, + () => { + result.errors.push('Missing value for --preset'); + } + ); + + remaining = applyRepeatedOption( + remaining, + ['--target'], + (value) => { + const target = parseTargetValue(value); + if (!target) { + result.errors.push(`Invalid --target value "${value}". Use: claude or droid`); + return; + } + result.target = target; + }, + () => { + result.errors.push('Missing value for --target'); + } + ); + + result.name = extractPositionalArgs(remaining)[0]; + return result; +} + +export function exitOnApiCommandErrors(errors: string[]): void { + if (errors.length === 0) { + return; + } + + errors.forEach((errorMessage) => { + console.log(fail(errorMessage)); + }); + process.exit(1); +} diff --git a/src/commands/config-auth/index.ts b/src/commands/config-auth/index.ts index 567ffed6..4fb7b70f 100644 --- a/src/commands/config-auth/index.ts +++ b/src/commands/config-auth/index.ts @@ -8,12 +8,35 @@ */ import { initUI, header, subheader, color, dim, fail } from '../../utils/ui'; +import { dispatchNamedCommand, type NamedCommandRoute } from '../named-command-router'; // Import command handlers import { handleSetup } from './setup-command'; import { handleShow } from './show-command'; import { handleDisable } from './disable-command'; +const CONFIG_AUTH_ROUTES: readonly NamedCommandRoute[] = [ + { + name: 'setup', + handle: async () => { + await handleSetup(); + }, + }, + { + name: 'show', + aliases: ['status'], + handle: async () => { + await handleShow(); + }, + }, + { + name: 'disable', + handle: async () => { + await handleDisable(); + }, + }, +]; + /** * Show help for config auth commands */ @@ -58,36 +81,20 @@ async function showHelp(): Promise { * Route config auth command to appropriate handler */ export async function handleConfigAuthCommand(args: string[]): Promise { - // Default to help if no subcommand - if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') { - await showHelp(); - return; - } - - const command = args[0]; - - switch (command) { - case 'setup': - await handleSetup(); - break; - - case 'show': - case 'status': - await handleShow(); - break; - - case 'disable': - await handleDisable(); - break; - - default: + await dispatchNamedCommand({ + args, + routes: CONFIG_AUTH_ROUTES, + onHelp: showHelp, + allowEmptyHelp: true, + onUnknown: async (command) => { await initUI(); console.log(fail(`Unknown command: ${command}`)); console.log(''); console.log('Run for help:'); console.log(` ${color('ccs config auth --help', 'command')}`); process.exit(1); - } + }, + }); } // Re-export types diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 87afcdd5..4296e8e1 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -14,6 +14,7 @@ import { ensureCliproxyService } from '../cliproxy/service-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; import { getDashboardAuthConfig } from '../config/unified-config-loader'; import { initUI, header, ok, info, warn, fail } from '../utils/ui'; +import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router'; import { isLoopbackHost, isWildcardHost, @@ -22,28 +23,39 @@ import { } from './config-dashboard-host'; import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options'; +const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [ + { + name: 'auth', + handle: async (args) => { + const { handleConfigAuthCommand } = await import('./config-auth'); + await handleConfigAuthCommand(args); + }, + }, + { + name: 'image-analysis', + handle: async (args) => { + const { handleConfigImageAnalysisCommand } = await import('./config-image-analysis-command'); + await handleConfigImageAnalysisCommand(args); + }, + }, + { + name: 'thinking', + handle: async (args) => { + const { handleConfigThinkingCommand } = await import('./config-thinking-command'); + await handleConfigThinkingCommand(args); + }, + }, +]; + /** * Handle config command */ export async function handleConfigCommand(args: string[]): Promise { - // Route subcommands before dashboard launch - if (args[0] === 'auth') { - const { handleConfigAuthCommand } = await import('./config-auth'); - await handleConfigAuthCommand(args.slice(1)); - return; - } - - // Route image-analysis subcommand - if (args[0] === 'image-analysis') { - const { handleConfigImageAnalysisCommand } = await import('./config-image-analysis-command'); - await handleConfigImageAnalysisCommand(args.slice(1)); - return; - } - - // Route thinking subcommand - if (args[0] === 'thinking') { - const { handleConfigThinkingCommand } = await import('./config-thinking-command'); - await handleConfigThinkingCommand(args.slice(1)); + const subcommand = args[0]?.startsWith('-') + ? undefined + : resolveNamedCommand(args[0], CONFIG_SUBCOMMAND_ROUTES); + if (subcommand) { + await subcommand.handle(args.slice(1)); return; } diff --git a/src/commands/named-command-router.ts b/src/commands/named-command-router.ts new file mode 100644 index 00000000..1748a094 --- /dev/null +++ b/src/commands/named-command-router.ts @@ -0,0 +1,58 @@ +export interface NamedCommandRoute { + name: string; + aliases?: readonly string[]; + handle(args: string[]): Promise | void; +} + +interface DispatchNamedCommandOptions { + args: string[]; + routes: readonly NamedCommandRoute[]; + onUnknown(command: string): Promise | void; + onHelp?: () => Promise | void; + helpTokens?: readonly string[]; + allowEmptyHelp?: boolean; +} + +const DEFAULT_HELP_TOKENS = ['help', '--help', '-h'] as const; + +export function resolveNamedCommand( + token: string | undefined, + routes: readonly NamedCommandRoute[] +): NamedCommandRoute | undefined { + if (!token) { + return undefined; + } + + return routes.find((route) => route.name === token || route.aliases?.includes(token)); +} + +export async function dispatchNamedCommand(options: DispatchNamedCommandOptions): Promise { + const { args, routes, onUnknown, onHelp, allowEmptyHelp = false } = options; + const helpTokens = options.helpTokens || DEFAULT_HELP_TOKENS; + const command = args[0]; + + if (!command) { + if (!allowEmptyHelp || !onHelp) { + return false; + } + await onHelp(); + return true; + } + + if (helpTokens.includes(command)) { + if (!onHelp) { + return false; + } + await onHelp(); + return true; + } + + const route = resolveNamedCommand(command, routes); + if (!route) { + await onUnknown(command); + return true; + } + + await route.handle(args.slice(1)); + return true; +} diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts new file mode 100644 index 00000000..07d72885 --- /dev/null +++ b/src/commands/root-command-router.ts @@ -0,0 +1,185 @@ +import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router'; + +async function printUpdateCommandHelp(): Promise { + console.log(''); + console.log('Usage: ccs update [options]'); + console.log(''); + console.log('Options:'); + console.log(' --force Force reinstall current version'); + console.log(' --beta, --dev Install from dev channel (unstable)'); + console.log(' --help, -h Show this help message'); + console.log(''); + console.log('Examples:'); + console.log(' ccs update Update to latest stable'); + console.log(' ccs update --force Force reinstall'); + console.log(' ccs update --beta Install dev channel'); + console.log(''); +} + +const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ + { + name: 'migrate', + aliases: ['--migrate'], + handle: async (args) => { + const { handleMigrateCommand, printMigrateHelp } = await import('./migrate-command'); + if (args.includes('--help') || args.includes('-h')) { + printMigrateHelp(); + return; + } + await handleMigrateCommand(args); + }, + }, + { + name: 'update', + aliases: ['--update'], + handle: async (args) => { + if (args.includes('--help') || args.includes('-h')) { + await printUpdateCommandHelp(); + return; + } + const { handleUpdateCommand } = await import('./update-command'); + await handleUpdateCommand({ + force: args.includes('--force'), + beta: args.includes('--beta') || args.includes('--dev'), + }); + }, + }, + { + name: 'version', + aliases: ['--version', '-v'], + handle: async () => { + const { handleVersionCommand } = await import('./version-command'); + await handleVersionCommand(); + }, + }, + { + name: 'help', + aliases: ['--help', '-h'], + handle: async () => { + const { handleHelpCommand } = await import('./help-command'); + await handleHelpCommand(); + }, + }, + { + name: '--install', + handle: async () => { + const { handleInstallCommand } = await import('./install-command'); + await handleInstallCommand(); + }, + }, + { + name: '--uninstall', + handle: async () => { + const { handleUninstallCommand } = await import('./install-command'); + await handleUninstallCommand(); + }, + }, + { + name: '--shell-completion', + aliases: ['-sc'], + handle: async (args) => { + const { handleShellCompletionCommand } = await import('./shell-completion-command'); + await handleShellCompletionCommand(args); + }, + }, + { + name: 'doctor', + aliases: ['--doctor'], + handle: async (args) => { + const { handleDoctorCommand } = await import('./doctor-command'); + await handleDoctorCommand(args); + }, + }, + { + name: 'sync', + aliases: ['--sync'], + handle: async () => { + const { handleSyncCommand } = await import('./sync-command'); + await handleSyncCommand(); + }, + }, + { + name: 'cleanup', + aliases: ['--cleanup'], + handle: async (args) => { + const { handleCleanupCommand } = await import('./cleanup-command'); + await handleCleanupCommand(args); + }, + }, + { + name: 'auth', + handle: async (args) => { + const AuthCommandsModule = await import('../auth/auth-commands'); + const AuthCommands = AuthCommandsModule.default; + const authCommands = new AuthCommands(); + await authCommands.route(args); + }, + }, + { + name: 'api', + handle: async (args) => { + const { handleApiCommand } = await import('./api-command'); + await handleApiCommand(args); + }, + }, + { + name: 'cliproxy', + handle: async (args) => { + const { handleCliproxyCommand } = await import('./cliproxy-command'); + await handleCliproxyCommand(args); + }, + }, + { + name: 'config', + handle: async (args) => { + const { handleConfigCommand } = await import('./config-command'); + await handleConfigCommand(args); + }, + }, + { + name: 'tokens', + handle: async (args) => { + const { handleTokensCommand } = await import('./tokens-command'); + process.exit(await handleTokensCommand(args)); + }, + }, + { + name: 'persist', + handle: async (args) => { + const { handlePersistCommand } = await import('./persist-command'); + await handlePersistCommand(args); + }, + }, + { + name: 'env', + handle: async (args) => { + const { handleEnvCommand } = await import('./env-command'); + await handleEnvCommand(args); + }, + }, + { + name: 'setup', + aliases: ['--setup'], + handle: async (args) => { + const { handleSetupCommand } = await import('./setup-command'); + await handleSetupCommand(args); + }, + }, + { + name: 'cursor', + handle: async (args) => { + const { handleCursorCommand } = await import('./cursor-command'); + process.exit(await handleCursorCommand(args)); + }, + }, +]; + +export async function tryHandleRootCommand(args: string[]): Promise { + const route = resolveNamedCommand(args[0], ROOT_COMMAND_ROUTES); + if (!route) { + return false; + } + + await route.handle(args.slice(1)); + return true; +} diff --git a/src/utils/fetch-proxy-setup.ts b/src/utils/fetch-proxy-setup.ts index 7883bc2f..604025ed 100644 --- a/src/utils/fetch-proxy-setup.ts +++ b/src/utils/fetch-proxy-setup.ts @@ -27,7 +27,7 @@ class RoutingProxyDispatcher extends Dispatcher { this.httpsProxyDispatcher = httpsProxyUrl ? new ProxyAgent(httpsProxyUrl) : null; } - dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean { + dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean { return this.resolveDispatcher(options.origin).dispatch(options, handler); } diff --git a/tests/unit/commands/api-command-router.test.ts b/tests/unit/commands/api-command-router.test.ts new file mode 100644 index 00000000..3fa54905 --- /dev/null +++ b/tests/unit/commands/api-command-router.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; + +let calls: string[] = []; + +beforeEach(() => { + calls = []; + + mock.module('../../../src/commands/api-command/help', () => ({ + showApiCommandHelp: async () => { + calls.push('help'); + }, + showUnknownApiCommand: async (command: string) => { + calls.push(`unknown:${command}`); + }, + })); + + mock.module('../../../src/commands/api-command/create-command', () => ({ + handleApiCreateCommand: async (args: string[]) => { + calls.push(`create:${args.join(' ')}`); + }, + })); + + mock.module('../../../src/commands/api-command/list-command', () => ({ + handleApiListCommand: async () => { + calls.push('list'); + }, + })); + + mock.module('../../../src/commands/api-command/remove-command', () => ({ + handleApiRemoveCommand: async (args: string[]) => { + calls.push(`remove:${args.join(' ')}`); + }, + })); + + mock.module('../../../src/commands/api-command/discover-command', () => ({ + handleApiDiscoverCommand: async (args: string[]) => { + calls.push(`discover:${args.join(' ')}`); + }, + })); + + mock.module('../../../src/commands/api-command/copy-command', () => ({ + handleApiCopyCommand: async (args: string[]) => { + calls.push(`copy:${args.join(' ')}`); + }, + })); + + mock.module('../../../src/commands/api-command/export-command', () => ({ + handleApiExportCommand: async (args: string[]) => { + calls.push(`export:${args.join(' ')}`); + }, + })); + + mock.module('../../../src/commands/api-command/import-command', () => ({ + handleApiImportCommand: async (args: string[]) => { + calls.push(`import:${args.join(' ')}`); + }, + })); +}); + +afterEach(() => { + mock.restore(); +}); + +async function loadHandleApiCommand() { + const mod = await import(`../../../src/commands/api-command?test=${Date.now()}-${Math.random()}`); + return mod.handleApiCommand; +} + +describe('api-command router', () => { + it('defaults to help when no subcommand is provided', async () => { + const handleApiCommand = await loadHandleApiCommand(); + + await handleApiCommand([]); + + expect(calls).toEqual(['help']); + }); + + it('routes remove aliases through the named command dispatcher', async () => { + const handleApiCommand = await loadHandleApiCommand(); + + await handleApiCommand(['rm', 'profile-a']); + + expect(calls).toEqual(['remove:profile-a']); + }); + + it('delegates unknown commands to the shared unknown handler', async () => { + const handleApiCommand = await loadHandleApiCommand(); + + await handleApiCommand(['bogus']); + + expect(calls).toEqual(['unknown:bogus']); + }); +}); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 14d76c51..11c7bb14 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; const startServerCalls: Array> = []; const resolveDashboardUrlsCalls: Array<[string | undefined, number]> = []; +const configAuthCalls: string[][] = []; let logLines: string[] = []; let errorLines: string[] = []; let dashboardAuthEnabled = false; @@ -14,6 +15,7 @@ let originalProcessExit: typeof process.exit; beforeEach(() => { startServerCalls.length = 0; resolveDashboardUrlsCalls.length = 0; + configAuthCalls.length = 0; logLines = []; errorLines = []; dashboardAuthEnabled = false; @@ -122,6 +124,12 @@ beforeEach(() => { }; }, })); + + mock.module('../../../src/commands/config-auth', () => ({ + handleConfigAuthCommand: async (args: string[]) => { + configAuthCalls.push([...args]); + }, + })); }); afterEach(() => { @@ -132,11 +140,23 @@ afterEach(() => { }); async function loadHandleConfigCommand() { - const mod = await import(`../../../src/commands/config-command?test=${Date.now()}-${Math.random()}`); + const mod = await import( + `../../../src/commands/config-command?test=${Date.now()}-${Math.random()}` + ); return mod.handleConfigCommand; } describe('config command dashboard startup', () => { + it('routes auth subcommands before dashboard startup', async () => { + const handleConfigCommand = await loadHandleConfigCommand(); + + await handleConfigCommand(['auth', 'setup']); + + expect(configAuthCalls).toEqual([['setup']]); + expect(startServerCalls).toHaveLength(0); + expect(resolveDashboardUrlsCalls).toHaveLength(0); + }); + it('keeps the default startup path free of an explicit host override', async () => { const handleConfigCommand = await loadHandleConfigCommand(); diff --git a/tests/unit/commands/root-command-router.test.ts b/tests/unit/commands/root-command-router.test.ts new file mode 100644 index 00000000..ef09cf7c --- /dev/null +++ b/tests/unit/commands/root-command-router.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; + +let calls: string[] = []; +let logLines: string[] = []; +let originalConsoleLog: typeof console.log; +let originalProcessExit: typeof process.exit; + +beforeEach(() => { + calls = []; + logLines = []; + originalConsoleLog = console.log; + originalProcessExit = process.exit; + + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + + mock.module('../../../src/commands/version-command', () => ({ + handleVersionCommand: async () => { + calls.push('version'); + }, + })); + + mock.module('../../../src/commands/update-command', () => ({ + handleUpdateCommand: async (options: Record) => { + calls.push(`update:${JSON.stringify(options)}`); + }, + })); + + mock.module('../../../src/commands/api-command', () => ({ + handleApiCommand: async (args: string[]) => { + calls.push(`api:${args.join(' ')}`); + }, + })); + + mock.module('../../../src/commands/tokens-command', () => ({ + handleTokensCommand: async () => 37, + })); +}); + +afterEach(() => { + console.log = originalConsoleLog; + process.exit = originalProcessExit; + mock.restore(); +}); + +async function loadTryHandleRootCommand() { + const mod = await import( + `../../../src/commands/root-command-router?test=${Date.now()}-${Math.random()}` + ); + return mod.tryHandleRootCommand; +} + +describe('root-command-router', () => { + it('routes command aliases to their handlers', async () => { + const tryHandleRootCommand = await loadTryHandleRootCommand(); + + await expect(tryHandleRootCommand(['--version'])).resolves.toBe(true); + + expect(calls).toEqual(['version']); + }); + + it('returns false for profile-like tokens that are not root commands', async () => { + const tryHandleRootCommand = await loadTryHandleRootCommand(); + + await expect(tryHandleRootCommand(['glm'])).resolves.toBe(false); + + expect(calls).toEqual([]); + }); + + it('prints update help without invoking the updater', async () => { + const tryHandleRootCommand = await loadTryHandleRootCommand(); + + await expect(tryHandleRootCommand(['update', '--help'])).resolves.toBe(true); + + expect(calls).toEqual([]); + expect(logLines.join('\n')).toContain('Usage: ccs update [options]'); + expect(logLines.join('\n')).toContain('ccs update --beta'); + }); + + it('passes remaining args through to nested command handlers', async () => { + const tryHandleRootCommand = await loadTryHandleRootCommand(); + + await expect(tryHandleRootCommand(['api', 'discover', '--register'])).resolves.toBe(true); + + expect(calls).toEqual(['api:discover --register']); + }); + + it('exits with the nested command exit code when required', async () => { + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + + const tryHandleRootCommand = await loadTryHandleRootCommand(); + + await expect(tryHandleRootCommand(['tokens', 'list'])).rejects.toThrow('process.exit(37)'); + }); +});