diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef7fd1e..52492f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# [5.3.0-beta.4](https://github.com/kaitranntt/ccs/compare/v5.3.0-beta.3...v5.3.0-beta.4) (2025-12-01) + + +### Bug Fixes + +* **cliproxy:** improve qwen oauth error handling ([#33](https://github.com/kaitranntt/ccs/issues/33)) ([1c3374f](https://github.com/kaitranntt/ccs/commit/1c3374f6a7e4440e299d49b58808c6454b4547c2)), closes [#29](https://github.com/kaitranntt/ccs/issues/29) + +# [5.3.0-beta.3](https://github.com/kaitranntt/ccs/compare/v5.3.0-beta.2...v5.3.0-beta.3) (2025-12-01) + + +### Bug Fixes + +* **prompt:** improve password input handling with raw mode and buffer support ([bc56d2e](https://github.com/kaitranntt/ccs/commit/bc56d2e135532b2ae443144dd42217b26bcba951)) + + +### Features + +* **profile:** add profile command with configuration display ([53d7a15](https://github.com/kaitranntt/ccs/commit/53d7a15c047e760723e051dc0f7be3c0dd42d087)) +* **shell-completion:** add --force flag and fix zsh profile coloring ([7faed1d](https://github.com/kaitranntt/ccs/commit/7faed1d84ba29ba02bf687bae5b3458617512e67)) + ## [5.2.1](https://github.com/kaitranntt/ccs/compare/v5.2.0...v5.2.1) (2025-12-01) diff --git a/VERSION b/VERSION index 26d99a28..fccc7b6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.2.1 +5.3.0-beta.4 diff --git a/installers/install.ps1 b/installers/install.ps1 index be14a02a..a56e47fb 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -83,7 +83,7 @@ $InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or ( # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (irm | iex) # For git installations, VERSION file is read if available -$CcsVersion = "5.2.1" +$CcsVersion = "5.3.0-beta.4" # Try to read VERSION file for git installations if ($ScriptDir) { diff --git a/installers/install.sh b/installers/install.sh index 6861ac20..69a891d4 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -84,7 +84,7 @@ fi # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (curl | bash) # For git installations, VERSION file is read if available -CCS_VERSION="5.2.1" +CCS_VERSION="5.3.0-beta.4" # Try to read VERSION file for git installations if [[ -f "$SCRIPT_DIR/VERSION" ]]; then diff --git a/package.json b/package.json index 0b4060b6..f1dd7544 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.2.1", + "version": "5.3.0-beta.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index 63f07fb1..2f134e6e 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -18,7 +18,7 @@ _ccs_completion() { # Top-level completion (first argument) if [[ ${COMP_CWORD} -eq 1 ]]; then - local commands="auth doctor sync update" + local commands="auth profile doctor sync update" local flags="--help --version --shell-completion -h -v -sc" local profiles="" @@ -45,6 +45,36 @@ _ccs_completion() { return 0 fi + # profile subcommands + if [[ ${prev} == "profile" ]]; then + local profile_commands="create list remove --help -h" + COMPREPLY=( $(compgen -W "${profile_commands}" -- ${cur}) ) + return 0 + fi + + # Completion for profile subcommands + if [[ ${COMP_WORDS[1]} == "profile" ]]; then + case "${prev}" in + remove|delete|rm) + # Complete with settings profile names + if [[ -f ~/.ccs/config.json ]]; then + local profiles=$(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true) + COMPREPLY=( $(compgen -W "${profiles}" -- ${cur}) ) + fi + return 0 + ;; + create) + # Complete with create flags + COMPREPLY=( $(compgen -W "--base-url --api-key --model --force --yes -y" -- ${cur}) ) + return 0 + ;; + list) + # No flags for list + return 0 + ;; + esac + fi + # Completion for auth subcommands that need profile names if [[ ${COMP_WORDS[1]} == "auth" ]]; then case "${prev}" in diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index dd68ea41..483c61d5 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -78,22 +78,23 @@ complete -c ccs -s v -l version -d 'Show version information' complete -c ccs -s sc -l shell-completion -d 'Install shell completion' # Commands - grouped with [cmd] prefix for visual distinction -complete -c ccs -n 'not __fish_seen_subcommand_from auth doctor sync update' -a 'auth' -d '[cmd] Manage multiple Claude accounts' -complete -c ccs -n 'not __fish_seen_subcommand_from auth doctor sync update' -a 'doctor' -d '[cmd] Run health check and diagnostics' -complete -c ccs -n 'not __fish_seen_subcommand_from auth doctor sync update' -a 'sync' -d '[cmd] Sync delegation commands and skills' -complete -c ccs -n 'not __fish_seen_subcommand_from auth doctor sync update' -a 'update' -d '[cmd] Update CCS to latest version' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a 'auth' -d '[cmd] Manage multiple Claude accounts' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a 'profile' -d '[cmd] Manage API profiles (create/remove)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a 'doctor' -d '[cmd] Run health check and diagnostics' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a 'sync' -d '[cmd] Sync delegation commands and skills' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a 'update' -d '[cmd] Update CCS to latest version' # Model profiles - grouped with [model] prefix for visual distinction -complete -c ccs -n 'not __fish_seen_subcommand_from auth doctor sync update' -a 'default' -d '[model] Default Claude Sonnet 4.5' -complete -c ccs -n 'not __fish_seen_subcommand_from auth doctor sync update' -a 'glm' -d '[model] GLM-4.6 (cost-optimized)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth doctor sync update' -a 'glmt' -d '[model] GLM-4.6 with thinking mode' -complete -c ccs -n 'not __fish_seen_subcommand_from auth doctor sync update' -a 'kimi' -d '[model] Kimi for Coding (long-context)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a 'default' -d '[model] Default Claude Sonnet 4.5' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a 'glm' -d '[model] GLM-4.6 (cost-optimized)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a 'glmt' -d '[model] GLM-4.6 with thinking mode' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a 'kimi' -d '[model] Kimi for Coding (long-context)' # Custom model profiles - dynamic with [model] prefix -complete -c ccs -n 'not __fish_seen_subcommand_from auth doctor sync update' -a '(__fish_ccs_get_custom_settings_profiles)' -d '[model] Settings-based profile' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a '(__fish_ccs_get_custom_settings_profiles)' -d '[model] Settings-based profile' # Account profiles - dynamic with [account] prefix -complete -c ccs -n 'not __fish_seen_subcommand_from auth doctor sync update' -a '(__fish_ccs_get_account_profiles)' -d '[account] Account-based profile' +complete -c ccs -n 'not __fish_seen_subcommand_from auth profile doctor sync update' -a '(__fish_ccs_get_account_profiles)' -d '[account] Account-based profile' # shell-completion subflags complete -c ccs -n '__fish_seen_argument -l shell-completion; or __fish_seen_argument -s sc' -l bash -d 'Install for bash' @@ -132,3 +133,35 @@ complete -c ccs -n '__fish_ccs_using_auth_subcommand default' -a '(__fish_ccs_ge # doctor command flags complete -c ccs -n '__fish_seen_subcommand_from doctor' -s h -l help -d 'Show help for doctor command' + +# Helper function to check if we're in profile context +function __fish_ccs_using_profile + __fish_seen_subcommand_from profile +end + +# Helper function to check specific profile subcommand +function __fish_ccs_using_profile_subcommand + set -l subcommand $argv[1] + __fish_ccs_using_profile; and __fish_seen_subcommand_from $subcommand +end + +# profile subcommands +complete -c ccs -n '__fish_ccs_using_profile; and not __fish_seen_subcommand_from create list remove' -a 'create' -d 'Create new API profile' +complete -c ccs -n '__fish_ccs_using_profile; and not __fish_seen_subcommand_from create list remove' -a 'list' -d 'List all profiles' +complete -c ccs -n '__fish_ccs_using_profile; and not __fish_seen_subcommand_from create list remove' -a 'remove' -d 'Remove a profile' + +# profile command flags +complete -c ccs -n '__fish_ccs_using_profile' -s h -l help -d 'Show help for profile commands' + +# profile create flags +complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l base-url -d 'API base URL' +complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l api-key -d 'API key' +complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l model -d 'Default model' +complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l force -d 'Overwrite existing profile' +complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l yes -d 'Skip prompts' +complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -s y -d 'Skip prompts' + +# profile remove - profile names and flags +complete -c ccs -n '__fish_ccs_using_profile_subcommand remove' -a '(__fish_ccs_get_settings_profiles)' -d 'Settings profile' +complete -c ccs -n '__fish_ccs_using_profile_subcommand remove' -l yes -d 'Skip confirmation' +complete -c ccs -n '__fish_ccs_using_profile_subcommand remove' -s y -d 'Skip confirmation' diff --git a/scripts/completion/ccs.ps1 b/scripts/completion/ccs.ps1 index 9c5fada5..5658610c 100644 --- a/scripts/completion/ccs.ps1 +++ b/scripts/completion/ccs.ps1 @@ -12,8 +12,10 @@ Register-ArgumentCompleter -CommandName ccs -ScriptBlock { param($commandName, $wordToComplete, $commandAst, $fakeBoundParameters) - $commands = @('auth', 'doctor', 'sync', 'update', '--help', '--version', '--shell-completion', '-h', '-v', '-sc') + $commands = @('auth', 'profile', 'doctor', 'sync', 'update', '--help', '--version', '--shell-completion', '-h', '-v', '-sc') $authCommands = @('create', 'list', 'show', 'remove', 'default', '--help', '-h') + $profileCommands = @('create', 'list', 'remove', '--help', '-h') + $profileCreateFlags = @('--base-url', '--api-key', '--model', '--force', '--yes', '-y') $shellCompletionFlags = @('--bash', '--zsh', '--fish', '--powershell') $listFlags = @('--verbose', '--json') $removeFlags = @('--yes', '-y') @@ -170,4 +172,58 @@ Register-ArgumentCompleter -CommandName ccs -ScriptBlock { } } } + + # profile subcommand completion + if ($words[1] -eq 'profile') { + if ($position -eq 3) { + # profile subcommands + $profileCommands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } elseif ($position -eq 4) { + # Profile names or flags for profile subcommands + switch ($words[2]) { + 'remove' { + $options = (Get-CcsProfiles -Type settings) + $removeFlags + $options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } + 'create' { + $profileCreateFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } + } + } elseif ($position -eq 5) { + # Flags after profile name + switch ($words[2]) { + 'remove' { + $removeFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } + } + } + } } diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index 501ee4c7..9b8f41bf 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -16,7 +16,7 @@ # Color codes: 0;34=blue, 0;32=green, 0;33=yellow, 2;37=dim white # Pattern format: =(#b)(group1)(group2)==color_for_group1=color_for_group2 # The leading '=' means no color for whole match, then each '=' assigns to each group -zstyle ':completion:*:*:ccs:*:commands' list-colors '=(#b)(auth|doctor|sync|update)([[:space:]]#--[[:space:]]#*)==0\;34=2\;37' +zstyle ':completion:*:*:ccs:*:commands' list-colors '=(#b)(auth|profile|doctor|sync|update)([[:space:]]#--[[:space:]]#*)==0\;34=2\;37' zstyle ':completion:*:*:ccs:*:model-profiles' list-colors '=(#b)(default|glm|glmt|kimi|[^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;32=2\;37' zstyle ':completion:*:*:ccs:*:account-profiles' list-colors '=(#b)([^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;33=2\;37' zstyle ':completion:*:*:ccs:*' group-name '' @@ -33,6 +33,7 @@ _ccs() { # Define top-level commands (padded for alignment) commands=( 'auth:Manage multiple Claude accounts' + 'profile:Manage API profiles (create/remove)' 'doctor:Run health check and diagnostics' 'sync:Sync delegation commands and skills' 'update:Update CCS to latest version' @@ -90,6 +91,9 @@ _ccs() { auth) _ccs_auth ;; + profile) + _ccs_profile + ;; doctor) _arguments \ '(- *)'{-h,--help}'[Show help for doctor command]' @@ -110,6 +114,58 @@ _ccs() { esac } +_ccs_profile() { + local curcontext="$curcontext" state line + typeset -A opt_args + + local -a profile_commands settings_profiles + + # Define profile subcommands + profile_commands=( + 'create:Create new API profile (interactive)' + 'list:List all profiles' + 'remove:Remove a profile' + ) + + # Load settings profiles for remove command + if [[ -f ~/.ccs/config.json ]]; then + settings_profiles=(${(f)"$(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null)"}) + fi + + _arguments -C \ + '(- *)'{-h,--help}'[Show help for profile commands]' \ + '1: :->subcommand' \ + '*:: :->subargs' + + case $state in + subcommand) + _describe -t profile-commands 'profile commands' profile_commands + ;; + + subargs) + case $words[1] in + create) + _arguments \ + '1:profile name:' \ + '--base-url[API base URL]:url:' \ + '--api-key[API key]:key:' \ + '--model[Default model]:model:' \ + '--force[Overwrite existing profile]' \ + {--yes,-y}'[Skip prompts]' + ;; + list) + # No arguments + ;; + remove|delete|rm) + _arguments \ + '1:profile:($settings_profiles)' \ + {--yes,-y}'[Skip confirmation]' + ;; + esac + ;; + esac +} + _ccs_auth() { local curcontext="$curcontext" state line typeset -A opt_args diff --git a/src/ccs.ts b/src/ccs.ts index 54c63046..5d228202 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -253,6 +253,13 @@ async function main(): Promise { return; } + // Special case: profile command + if (firstArg === 'profile') { + const { handleProfileCommand } = await import('./commands/profile-command'); + await handleProfileCommand(args.slice(1)); + return; + } + // Special case: headless delegation (-p flag) if (args.includes('-p') || args.includes('--prompt')) { const { DelegationHandler } = await import('./delegation/delegation-handler'); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 07b406d5..370e0aa6 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -13,15 +13,18 @@ export function handleHelpCommand(): void { console.log(''); // ═══════════════════════════════════════════════════════════════════════════ - // SECTION 1: API KEY MODELS + // SECTION 1: API KEY MODELS + PROFILE MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ - console.log(colored('═══ API Key Models ═══', 'cyanBold')); - console.log(' Configure API keys in ~/.ccs/*.settings.json'); + console.log(colored('═══ API Key Profiles ═══', 'cyanBold')); + console.log(' Configure in ~/.ccs/*.settings.json'); console.log(''); console.log(` ${colored('ccs', 'yellow')} Use default Claude account`); console.log(` ${colored('ccs glm', 'yellow')} GLM 4.6 (API key required)`); console.log(` ${colored('ccs glmt', 'yellow')} GLM with thinking mode`); console.log(` ${colored('ccs kimi', 'yellow')} Kimi for Coding (API key)`); + console.log(` ${colored('ccs profile create', 'yellow')} Create custom API profile`); + console.log(` ${colored('ccs profile remove', 'yellow')} Remove a profile`); + console.log(` ${colored('ccs profile list', 'yellow')} List all profiles`); console.log(''); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/commands/profile-command.ts b/src/commands/profile-command.ts new file mode 100644 index 00000000..0f09c50f --- /dev/null +++ b/src/commands/profile-command.ts @@ -0,0 +1,423 @@ +/** + * Profile Command Handler + * + * Manages CCS profiles for custom API providers. + * Commands: create, list + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { colored } from '../utils/helpers'; +import { InteractivePrompt } from '../utils/prompt'; +import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; + +interface ProfileCommandArgs { + name?: string; + baseUrl?: string; + apiKey?: string; + model?: string; + force?: boolean; + yes?: boolean; +} + +/** + * Parse command line arguments for profile commands + */ +function parseArgs(args: string[]): ProfileCommandArgs { + const result: ProfileCommandArgs = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--base-url' && args[i + 1]) { + result.baseUrl = args[++i]; + } else if (arg === '--api-key' && args[i + 1]) { + result.apiKey = args[++i]; + } else if (arg === '--model' && args[i + 1]) { + result.model = args[++i]; + } else if (arg === '--force') { + result.force = true; + } else if (arg === '--yes' || arg === '-y') { + result.yes = true; + } else if (!arg.startsWith('-') && !result.name) { + result.name = arg; + } + } + + return result; +} + +/** + * Validate profile name + */ +function validateProfileName(name: string): string | null { + if (!name) { + return 'Profile name is required'; + } + if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { + return 'Profile name must start with letter, contain only letters, numbers, dot, dash, underscore'; + } + if (name.length > 32) { + return 'Profile name must be 32 characters or less'; + } + // Reserved names + const reserved = ['default', 'auth', 'profile', 'doctor', 'sync', 'update', 'help', 'version']; + if (reserved.includes(name.toLowerCase())) { + return `'${name}' is a reserved name`; + } + return null; +} + +/** + * Validate URL format + */ +function validateUrl(url: string): string | null { + if (!url) { + return 'Base URL is required'; + } + try { + new URL(url); + return null; + } catch { + return 'Invalid URL format (must include protocol, e.g., https://)'; + } +} + +/** + * Check if profile already exists in config.json + */ +function profileExists(name: string): boolean { + try { + const config = loadConfig(); + return name in config.profiles; + } catch { + return false; + } +} + +/** + * Create settings.json file for profile + */ +function createSettingsFile(name: string, baseUrl: string, apiKey: string, model: string): string { + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${name}.settings.json`); + + const settings = { + env: { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: apiKey, + ANTHROPIC_MODEL: model, + }, + }; + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + return settingsPath; +} + +/** + * Update config.json with new profile + */ +function updateConfig(name: string, _settingsPath: string): void { + const configPath = getConfigPath(); + const ccsDir = getCcsDir(); + + // Read existing config or create new + let config: { profiles: Record; cliproxy?: Record }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + config = { profiles: {} }; + } + + // Use relative path with ~ for portability + const relativePath = `~/.ccs/${name}.settings.json`; + config.profiles[name] = relativePath; + + // Ensure directory exists + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); + } + + // Write config atomically (write to temp, then rename) + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); +} + +/** + * Handle 'ccs profile create' command + */ +async function handleCreate(args: string[]): Promise { + const parsedArgs = parseArgs(args); + + console.log(colored('Create API Profile', 'bold')); + console.log(''); + + // Step 1: Profile name + let name = parsedArgs.name; + if (!name) { + name = await InteractivePrompt.input('Profile name', { + validate: validateProfileName, + }); + } else { + const error = validateProfileName(name); + if (error) { + console.error(`[X] ${error}`); + process.exit(1); + } + } + + // Check if exists + if (profileExists(name) && !parsedArgs.force) { + console.error(`[X] Profile '${name}' already exists`); + console.log(` Use ${colored('--force', 'yellow')} to overwrite`); + process.exit(1); + } + + // Step 2: Base URL + let baseUrl = parsedArgs.baseUrl; + if (!baseUrl) { + baseUrl = await InteractivePrompt.input('API Base URL (e.g., https://api.example.com)', { + validate: validateUrl, + }); + } else { + const error = validateUrl(baseUrl); + if (error) { + console.error(`[X] ${error}`); + process.exit(1); + } + } + + // Step 3: API Key + let apiKey = parsedArgs.apiKey; + if (!apiKey) { + apiKey = await InteractivePrompt.password('API Key'); + if (!apiKey) { + console.error('[X] API key is required'); + process.exit(1); + } + } + + // Step 4: Model (optional) + const defaultModel = 'claude-sonnet-4-5-20250929'; + let model = parsedArgs.model; + if (!model && !parsedArgs.yes) { + model = await InteractivePrompt.input('Default model', { + default: defaultModel, + }); + } + model = model || defaultModel; + + // Create files + console.log(''); + console.log('[i] Creating profile...'); + + try { + const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); + updateConfig(name, settingsPath); + + console.log(colored('[OK] Profile created successfully', 'green')); + console.log(''); + console.log(` Profile: ${name}`); + console.log(` Settings: ~/.ccs/${name}.settings.json`); + console.log(` Base URL: ${baseUrl}`); + console.log(` Model: ${model}`); + console.log(''); + console.log(colored('Usage:', 'cyan')); + console.log(` ${colored(`ccs ${name} "your prompt"`, 'yellow')}`); + console.log(''); + } catch (error) { + console.error(`[X] Failed to create profile: ${(error as Error).message}`); + process.exit(1); + } +} + +/** + * Handle 'ccs profile list' command + */ +async function handleList(): Promise { + console.log(colored('CCS Profiles', 'bold')); + console.log(''); + + try { + const config = loadConfig(); + const profiles = Object.keys(config.profiles); + + if (profiles.length === 0) { + console.log(colored('No profiles configured', 'yellow')); + console.log(''); + console.log('To create a profile:'); + console.log(` ${colored('ccs profile create', 'yellow')}`); + console.log(''); + return; + } + + console.log(colored('Settings-based Profiles:', 'cyan')); + // Calculate max name length for alignment + const maxNameLen = Math.max(...profiles.map((n) => n.length)); + profiles.forEach((name) => { + const settingsPath = config.profiles[name]; + const paddedName = name.padEnd(maxNameLen); + console.log(` ${colored(paddedName, 'yellow')} ${settingsPath}`); + }); + console.log(''); + + // Also show CLIProxy profiles if any + if (config.cliproxy && Object.keys(config.cliproxy).length > 0) { + console.log(colored('CLIProxy Variants:', 'cyan')); + Object.entries(config.cliproxy).forEach(([name, variant]) => { + const v = variant as { provider: string; settings: string }; + console.log(` ${colored(name, 'yellow')} → ${v.provider} (${v.settings})`); + }); + console.log(''); + } + + console.log(`Total: ${profiles.length} profile(s)`); + console.log(''); + } catch (error) { + console.error(`[X] Failed to list profiles: ${(error as Error).message}`); + process.exit(1); + } +} + +/** + * Handle 'ccs profile remove' command + */ +async function handleRemove(args: string[]): Promise { + const parsedArgs = parseArgs(args); + + // Load config first to get available profiles + let config: { profiles: Record; cliproxy?: Record }; + try { + config = loadConfig(); + } catch { + console.error('[X] Failed to load config'); + process.exit(1); + } + + const profiles = Object.keys(config.profiles); + if (profiles.length === 0) { + console.log(colored('No profiles to remove', 'yellow')); + process.exit(0); + } + + // Interactive profile selection if not provided + let name = parsedArgs.name; + if (!name) { + console.log(colored('Remove Profile', 'bold')); + console.log(''); + console.log('Available profiles:'); + profiles.forEach((p, i) => console.log(` ${i + 1}. ${p}`)); + console.log(''); + + name = await InteractivePrompt.input('Profile name to remove', { + validate: (val) => { + if (!val) return 'Profile name is required'; + if (!profiles.includes(val)) return `Profile '${val}' not found`; + return null; + }, + }); + } + + if (!(name in config.profiles)) { + console.error(`[X] Profile '${name}' not found`); + console.log(''); + console.log('Available profiles:'); + profiles.forEach((p) => console.log(` - ${p}`)); + process.exit(1); + } + + const settingsPath = config.profiles[name]; + const expandedPath = path.join(getCcsDir(), `${name}.settings.json`); + + // Confirm deletion + console.log(''); + console.log(`Profile '${colored(name, 'yellow')}' will be removed.`); + console.log(` Settings: ${settingsPath}`); + console.log(''); + + const confirmed = + parsedArgs.yes || (await InteractivePrompt.confirm('Delete this profile?', { default: false })); + + if (!confirmed) { + console.log('[i] Cancelled'); + process.exit(0); + } + + // Remove from config.json + delete config.profiles[name]; + const configPath = getConfigPath(); + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); + + // Remove settings file if it exists + if (fs.existsSync(expandedPath)) { + fs.unlinkSync(expandedPath); + } + + console.log(colored('[OK] Profile removed', 'green')); + console.log(` Profile: ${name}`); + console.log(''); +} + +/** + * Show help for profile commands + */ +function showHelp(): void { + console.log(colored('CCS Profile Management', 'bold')); + console.log(''); + console.log(colored('Usage:', 'cyan')); + console.log(` ${colored('ccs profile', 'yellow')} [options]`); + console.log(''); + console.log(colored('Commands:', 'cyan')); + console.log(` ${colored('create [name]', 'yellow')} Create new API profile (interactive)`); + console.log(` ${colored('list', 'yellow')} List all profiles`); + console.log(` ${colored('remove ', 'yellow')} Remove a profile`); + console.log(''); + console.log(colored('Options:', 'cyan')); + console.log(` ${colored('--base-url ', 'yellow')} API base URL (create)`); + console.log(` ${colored('--api-key ', 'yellow')} API key (create)`); + console.log(` ${colored('--model ', 'yellow')} Default model (create)`); + console.log(` ${colored('--force', 'yellow')} Overwrite existing (create)`); + console.log(` ${colored('--yes, -y', 'yellow')} Skip confirmation prompts`); + console.log(''); + console.log(colored('Examples:', 'cyan')); + console.log(` ${colored('ccs profile create', 'yellow')} # Interactive wizard`); + console.log(` ${colored('ccs profile create myapi', 'yellow')} # Create with name`); + console.log(` ${colored('ccs profile remove myapi', 'yellow')} # Remove profile`); + console.log(` ${colored('ccs profile list', 'yellow')} # Show all profiles`); + console.log(''); +} + +/** + * Main profile command router + */ +export async function handleProfileCommand(args: string[]): Promise { + const command = args[0]; + + if (!command || command === '--help' || command === '-h' || command === 'help') { + showHelp(); + return; + } + + switch (command) { + case 'create': + await handleCreate(args.slice(1)); + break; + case 'list': + await handleList(); + break; + case 'remove': + case 'delete': + case 'rm': + await handleRemove(args.slice(1)); + break; + default: + console.error(`[X] Unknown command: ${command}`); + console.log(''); + console.log('Run for help:'); + console.log(` ${colored('ccs profile --help', 'yellow')}`); + process.exit(1); + } +} diff --git a/src/commands/shell-completion-command.ts b/src/commands/shell-completion-command.ts index b8b8117a..804c7f7f 100644 --- a/src/commands/shell-completion-command.ts +++ b/src/commands/shell-completion-command.ts @@ -17,6 +17,7 @@ export async function handleShellCompletionCommand(args: string[]): Promise string | null; } +interface PasswordOptions { + mask?: string; // Character to show (default: '*') +} + export class InteractivePrompt { /** * Ask for confirmation @@ -127,4 +131,78 @@ export class InteractivePrompt { }); }); } + + /** + * Get password/secret input (masked) + */ + static async password(message: string, options: PasswordOptions = {}): Promise { + const { mask = '*' } = options; + + // Non-TTY: error (passwords require interactive input) + if (!process.stdin.isTTY) { + throw new Error('Password input requires interactive TTY'); + } + + // Set raw mode BEFORE writing prompt to prevent any echo + if (process.stdin.setRawMode) { + process.stdin.setRawMode(true); + } + + const promptText = `${message}: `; + process.stderr.write(promptText); + + return new Promise((resolve) => { + let input = ''; + + const cleanup = (): void => { + if (process.stdin.setRawMode) { + process.stdin.setRawMode(false); + } + process.stdin.removeListener('data', onData); + }; + + const onData = (data: Buffer): void => { + // Convert buffer to string and process each character + const str = data.toString('utf8'); + + for (const char of str) { + const charCode = char.charCodeAt(0); + + // Enter key (CR or LF) + if (charCode === 13 || charCode === 10) { + cleanup(); + process.stderr.write('\n'); + resolve(input); + return; + } + + // Ctrl+C + if (charCode === 3) { + cleanup(); + process.stderr.write('\n'); + process.exit(130); + } + + // Backspace (127 or 8) + if (charCode === 127 || charCode === 8) { + if (input.length > 0) { + input = input.slice(0, -1); + // Move cursor back, overwrite with space, move back again + process.stderr.write('\b \b'); + } + continue; + } + + // Regular printable character (ignore control chars and newlines in paste) + if (charCode >= 32) { + input += char; + process.stderr.write(mask); + } + } + }; + + process.stdin.on('data', onData); + process.stdin.resume(); + }); + } } diff --git a/src/utils/shell-completion.ts b/src/utils/shell-completion.ts index eef7919a..68419571 100644 --- a/src/utils/shell-completion.ts +++ b/src/utils/shell-completion.ts @@ -92,7 +92,7 @@ export class ShellCompletionInstaller { /** * Install bash completion */ - private installBash(): InstallResult { + private installBash(force = false): InstallResult { const rcFile = path.join(this.homeDir, '.bashrc'); const completionPath = path.join(this.completionDir, 'ccs.bash'); @@ -108,7 +108,15 @@ export class ShellCompletionInstaller { if (fs.existsSync(rcFile)) { const content = fs.readFileSync(rcFile, 'utf8'); if (content.includes(marker)) { - return { success: true, alreadyInstalled: true }; + if (!force) { + return { success: true, alreadyInstalled: true }; + } + // Force: completion files already updated by ensureCompletionFiles() + return { + success: true, + message: `Updated completion files in ${this.completionDir}`, + reload: 'source ~/.bashrc', + }; } } @@ -125,7 +133,7 @@ export class ShellCompletionInstaller { /** * Install zsh completion */ - private installZsh(): InstallResult { + private installZsh(force = false): InstallResult { const rcFile = path.join(this.homeDir, '.zshrc'); const completionPath = path.join(this.completionDir, 'ccs.zsh'); const zshCompDir = path.join(this.homeDir, '.zsh', 'completion'); @@ -137,7 +145,7 @@ export class ShellCompletionInstaller { // Create zsh completion directory (with file conflict checking) this.ensureDirectory(zshCompDir); - // Copy to zsh completion directory + // Copy to zsh completion directory (always update for force) const destFile = path.join(zshCompDir, '_ccs'); fs.copyFileSync(completionPath, destFile); @@ -149,7 +157,15 @@ export class ShellCompletionInstaller { if (fs.existsSync(rcFile)) { const content = fs.readFileSync(rcFile, 'utf8'); if (content.includes(marker)) { - return { success: true, alreadyInstalled: true }; + if (!force) { + return { success: true, alreadyInstalled: true }; + } + // Force: files already updated above + return { + success: true, + message: `Updated ${destFile}`, + reload: 'source ~/.zshrc', + }; } } @@ -166,7 +182,7 @@ export class ShellCompletionInstaller { /** * Install fish completion */ - private installFish(): InstallResult { + private installFish(force = false): InstallResult { const completionPath = path.join(this.completionDir, 'ccs.fish'); const fishCompDir = path.join(this.homeDir, '.config', 'fish', 'completions'); @@ -179,6 +195,12 @@ export class ShellCompletionInstaller { // Copy to fish completion directory (fish auto-loads from here) const destFile = path.join(fishCompDir, 'ccs.fish'); + + // Check if already installed + if (fs.existsSync(destFile) && !force) { + return { success: true, alreadyInstalled: true }; + } + fs.copyFileSync(completionPath, destFile); return { @@ -191,7 +213,7 @@ export class ShellCompletionInstaller { /** * Install PowerShell completion */ - private installPowerShell(): InstallResult { + private installPowerShell(force = false): InstallResult { const profilePath = process.env.PROFILE || path.join(this.homeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'); @@ -213,7 +235,15 @@ export class ShellCompletionInstaller { if (fs.existsSync(profilePath)) { const content = fs.readFileSync(profilePath, 'utf8'); if (content.includes(marker)) { - return { success: true, alreadyInstalled: true }; + if (!force) { + return { success: true, alreadyInstalled: true }; + } + // Force: completion files already updated by ensureCompletionFiles() + return { + success: true, + message: `Updated completion files in ${this.completionDir}`, + reload: '. $PROFILE', + }; } } @@ -230,8 +260,9 @@ export class ShellCompletionInstaller { /** * Install for detected or specified shell */ - install(shell: ShellType = null): InstallResult { + install(shell: ShellType = null, options: { force?: boolean } = {}): InstallResult { const targetShell = shell || this.detectShell(); + const { force = false } = options; if (!targetShell) { throw new Error( @@ -239,19 +270,19 @@ export class ShellCompletionInstaller { ); } - // Ensure completion files exist + // Ensure completion files exist (always copy latest) this.ensureCompletionFiles(); // Install for target shell switch (targetShell) { case 'bash': - return this.installBash(); + return this.installBash(force); case 'zsh': - return this.installZsh(); + return this.installZsh(force); case 'fish': - return this.installFish(); + return this.installFish(force); case 'powershell': - return this.installPowerShell(); + return this.installPowerShell(force); default: throw new Error(`Unsupported shell: ${targetShell}`); }