From 0341f4f86f5598dc3a86c06d2a05e22b2af3342d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 12:36:24 -0500 Subject: [PATCH] refactor(auth): modularize auth-commands into commands/ directory - extract create, list, show, remove, default command handlers - extract types.ts with CommandContext and parseArgs - slim auth-commands.ts from 725 to 235 lines (68% reduction) - add barrel exports at commands/index.ts and auth/index.ts --- src/auth/auth-commands.ts | 574 ++------------------------- src/auth/commands/create-command.ts | 148 +++++++ src/auth/commands/default-command.ts | 71 ++++ src/auth/commands/index.ts | 22 + src/auth/commands/list-command.ts | 138 +++++++ src/auth/commands/remove-command.ts | 86 ++++ src/auth/commands/show-command.ts | 84 ++++ src/auth/commands/types.ts | 80 ++++ src/auth/index.ts | 8 + 9 files changed, 679 insertions(+), 532 deletions(-) create mode 100644 src/auth/commands/create-command.ts create mode 100644 src/auth/commands/default-command.ts create mode 100644 src/auth/commands/index.ts create mode 100644 src/auth/commands/list-command.ts create mode 100644 src/auth/commands/remove-command.ts create mode 100644 src/auth/commands/show-command.ts create mode 100644 src/auth/commands/types.ts create mode 100644 src/auth/index.ts diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index c4abadfd..23efc106 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -1,70 +1,37 @@ /** - * Auth Commands (Simplified) + * Auth Commands (Facade) * * CLI interface for CCS multi-account management. - * Commands: create, list, show, remove, default + * Commands: create, list, show, remove, default, reset-default * * Login-per-profile model: Each profile is an isolated Claude instance. * Users login directly in each instance (no credential copying). * - * Supports dual-mode configuration: - * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists - * - Legacy JSON format (profiles.json) as fallback + * Implementation Note: This is a facade that delegates to modular command handlers. + * See ./commands/ for individual command implementations. */ -import { spawn, ChildProcess } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; import ProfileRegistry from './profile-registry'; -import { ProfileMetadata } from '../types'; import { InstanceManager } from '../management/instance-manager'; -import { - initUI, - header, - subheader, - color, - dim, - ok, - fail, - warn, - info, - table, - infoBox, - warnBox, -} from '../utils/ui'; -import { getClaudeCliInfo } from '../utils/claude-detector'; -import { escapeShellArg } from '../utils/shell-executor'; -import { InteractivePrompt } from '../utils/prompt'; +import { initUI, header, subheader, color, dim, warn, fail } from '../utils/ui'; import packageJson from '../../package.json'; -import { isUnifiedMode } from '../config/unified-config-loader'; -import { exitWithError } from '../errors'; -import { ExitCode } from '../errors/exit-codes'; -interface AuthCommandArgs { - profileName?: string; - force?: boolean; - verbose?: boolean; - json?: boolean; - yes?: boolean; -} - -interface ProfileOutput { - name: string; - type: string; - is_default: boolean; - created: string; - last_used: string | null; - instance_path?: string; - session_count?: number; -} - -interface ListOutput { - version: string; - profiles: ProfileOutput[]; -} +// Import command handlers from modular structure +import { + type CommandContext, + handleCreate, + handleList, + handleShow, + handleRemove, + handleDefault, + handleResetDefault, +} from './commands'; /** - * Auth Commands Class + * Auth Commands Class (Facade) + * + * Maintains class API for backward compatibility while delegating + * to modular command handlers. */ class AuthCommands { private registry: ProfileRegistry; @@ -76,6 +43,17 @@ class AuthCommands { this.instanceMgr = new InstanceManager(); } + /** + * Get command context for handlers + */ + private getContext(): CommandContext { + return { + registry: this.registry, + instanceMgr: this.instanceMgr, + version: this.version, + }; + } + /** * Show help for auth commands */ @@ -138,513 +116,45 @@ class AuthCommands { } /** - * Parse command arguments - */ - private parseArgs(args: string[]): AuthCommandArgs { - const profileName = args.find((arg) => !arg.startsWith('--')); - return { - profileName, - force: args.includes('--force'), - verbose: args.includes('--verbose'), - json: args.includes('--json'), - yes: args.includes('--yes') || args.includes('-y'), - }; - } - - /** - * Create new profile and prompt for login + * Create new profile - delegates to create-command.ts */ async handleCreate(args: string[]): Promise { - await initUI(); - const { profileName, force } = this.parseArgs(args); - - if (!profileName) { - console.log(fail('Profile name is required')); - console.log(''); - console.log(`Usage: ${color('ccs auth create [--force]', 'command')}`); - console.log(''); - console.log('Example:'); - console.log(` ${color('ccs auth create work', 'command')}`); - exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); - } - - // Check if profile already exists (check both legacy and unified) - const existsLegacy = this.registry.hasProfile(profileName); - const existsUnified = this.registry.hasAccountUnified(profileName); - if (!force && (existsLegacy || existsUnified)) { - console.log(fail(`Profile already exists: ${profileName}`)); - console.log(` Use ${color('--force', 'command')} to overwrite`); - exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR); - } - - try { - // Create instance directory - console.log(info(`Creating profile: ${profileName}`)); - const instancePath = this.instanceMgr.ensureInstance(profileName); - - // Create/update profile entry based on config mode - if (isUnifiedMode()) { - // Use unified config (config.yaml) - if (existsUnified) { - this.registry.touchAccountUnified(profileName); - } else { - this.registry.createAccountUnified(profileName); - } - } else { - // Use legacy profiles.json - if (existsLegacy) { - this.registry.updateProfile(profileName, { - type: 'account', - }); - } else { - this.registry.createProfile(profileName, { - type: 'account', - }); - } - } - - console.log(info(`Instance directory: ${instancePath}`)); - console.log(''); - console.log(warn('Starting Claude in isolated instance...')); - console.log(warn('You will be prompted to login with your account.')); - console.log(''); - - // Detect Claude CLI - const claudeInfo = getClaudeCliInfo(); - if (!claudeInfo) { - console.log(fail('Claude CLI not found')); - console.log(''); - console.log('Please install Claude CLI first:'); - console.log(` ${color('https://claude.ai/download', 'path')}`); - exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR); - } - - const { path: claudeCli, needsShell } = claudeInfo; - - // Execute Claude in isolated instance (will auto-prompt for login if no credentials) - // On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly - let child: ChildProcess; - if (needsShell) { - const cmdString = escapeShellArg(claudeCli); - child = spawn(cmdString, { - stdio: 'inherit', - windowsHide: true, - shell: true, - env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath }, - }); - } else { - child = spawn(claudeCli, [], { - stdio: 'inherit', - windowsHide: true, - env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath }, - }); - } - - child.on('exit', (code: number | null) => { - if (code === 0) { - console.log(''); - console.log( - infoBox( - `Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`, - 'Profile Created' - ) - ); - console.log(''); - console.log(header('Usage')); - console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`); - console.log(''); - console.log( - warnBox( - `Running the command below will SWITCH your default\n` + - `CCS account to "${profileName}". After this, running\n` + - `"ccs" without a profile name will use this account.\n\n` + - ` ${color(`ccs auth default ${profileName}`, 'command')}\n\n` + - `To restore the original default, run:\n` + - ` ${color('ccs auth reset-default', 'command')}`, - 'Set as Default?' - ) - ); - console.log(''); - process.exit(0); - } else { - console.log(''); - console.log(fail('Login failed or cancelled')); - console.log(''); - console.log('To retry:'); - console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`); - console.log(''); - exitWithError('Login failed or cancelled', ExitCode.AUTH_ERROR); - } - }); - - child.on('error', (err: Error) => { - exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR); - }); - } catch (error) { - exitWithError( - `Failed to create profile: ${(error as Error).message}`, - ExitCode.GENERAL_ERROR - ); - } + return handleCreate(this.getContext(), args); } /** - * Format relative time (e.g., "2h ago", "1d ago") - */ - private formatRelativeTime(date: Date): string { - const now = Date.now(); - const diff = now - date.getTime(); - - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(diff / 3600000); - const days = Math.floor(diff / 86400000); - - if (days > 0) return `${days}d ago`; - if (hours > 0) return `${hours}h ago`; - if (minutes > 0) return `${minutes}m ago`; - return 'just now'; - } - - /** - * List all saved profiles + * List all profiles - delegates to list-command.ts */ async handleList(args: string[]): Promise { - await initUI(); - const { verbose, json } = this.parseArgs(args); - - try { - // Get profiles from both legacy (profiles.json) and unified config (config.yaml) - const legacyProfiles = this.registry.getAllProfiles(); - const unifiedAccounts = this.registry.getAllAccountsUnified(); - - // Merge profiles: unified config takes precedence - const profiles: Record = { ...legacyProfiles }; - for (const [name, account] of Object.entries(unifiedAccounts)) { - profiles[name] = { - type: 'account', - created: account.created, - last_used: account.last_used, - }; - } - - const defaultProfile = this.registry.getDefaultUnified() ?? this.registry.getDefaultProfile(); - const profileNames = Object.keys(profiles); - - // JSON output mode - if (json) { - const output: ListOutput = { - version: this.version, - profiles: profileNames.map((name) => { - const profile = profiles[name]; - const isDefault = name === defaultProfile; - const instancePath = this.instanceMgr.getInstancePath(name); - - return { - name: name, - type: profile.type || 'account', - is_default: isDefault, - created: profile.created, - last_used: profile.last_used || null, - instance_path: instancePath, - }; - }), - }; - console.log(JSON.stringify(output, null, 2)); - return; - } - - // Human-readable output - if (profileNames.length === 0) { - console.log(warn('No account profiles found')); - console.log(''); - console.log('To create your first profile:'); - console.log(` ${color('ccs auth create ', 'command')}`); - console.log(''); - console.log('Example:'); - console.log(` ${color('ccs auth create work', 'command')}`); - console.log(''); - return; - } - - console.log(header('Saved Account Profiles')); - console.log(''); - - // Sort by last_used (descending), then alphabetically - const sorted = profileNames.sort((a, b) => { - const aProfile = profiles[a]; - const bProfile = profiles[b]; - - // Default first - if (a === defaultProfile) return -1; - if (b === defaultProfile) return 1; - - // Then by last_used - if (aProfile.last_used && bProfile.last_used) { - return new Date(bProfile.last_used).getTime() - new Date(aProfile.last_used).getTime(); - } - if (aProfile.last_used) return -1; - if (bProfile.last_used) return 1; - - // Then alphabetically - return a.localeCompare(b); - }); - - // Build table rows - const rows: string[][] = sorted.map((name) => { - const profile = profiles[name]; - const isDefault = name === defaultProfile; - - // Status column - const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success'); - - // Last used column - let lastUsed = '-'; - if (profile.last_used) { - lastUsed = this.formatRelativeTime(new Date(profile.last_used)); - } - - const row = [ - color(name, isDefault ? 'primary' : 'info'), - profile.type || 'account', - status, - ]; - - if (verbose) { - row.push(lastUsed); - } - - return row; - }); - - // Headers - const headers = verbose - ? ['Profile', 'Type', 'Status', 'Last Used'] - : ['Profile', 'Type', 'Status']; - - // Print table - console.log( - table(rows, { - head: headers, - colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15], - }) - ); - console.log(''); - console.log(dim(`Total: ${profileNames.length} profile(s)`)); - console.log(''); - } catch (error) { - exitWithError(`Failed to list profiles: ${(error as Error).message}`, ExitCode.GENERAL_ERROR); - } + return handleList(this.getContext(), args); } /** - * Show details for a specific profile + * Show profile details - delegates to show-command.ts */ async handleShow(args: string[]): Promise { - await initUI(); - const { profileName, json } = this.parseArgs(args); - - if (!profileName) { - console.log(fail('Profile name is required')); - console.log(''); - console.log(`Usage: ${color('ccs auth show [--json]', 'command')}`); - exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); - } - - try { - const profile = this.registry.getProfile(profileName); - const defaultProfile = this.registry.getDefaultProfile(); - const isDefault = profileName === defaultProfile; - const instancePath = this.instanceMgr.getInstancePath(profileName); - - // Count sessions - let sessionCount = 0; - try { - const sessionsDir = path.join(instancePath, 'session-env'); - if (fs.existsSync(sessionsDir)) { - const files = fs.readdirSync(sessionsDir); - sessionCount = files.filter((f) => f.endsWith('.json')).length; - } - } catch (_e) { - // Ignore errors counting sessions - } - - // JSON output mode - if (json) { - const output: ProfileOutput = { - name: profileName, - type: profile.type || 'account', - is_default: isDefault, - created: profile.created, - last_used: profile.last_used || null, - instance_path: instancePath, - session_count: sessionCount, - }; - console.log(JSON.stringify(output, null, 2)); - return; - } - - // Human-readable output - const defaultBadge = isDefault ? color(' (default)', 'success') : ''; - console.log(header(`Profile: ${profileName}${defaultBadge}`)); - console.log(''); - - // Details table - const details = [ - ['Type', profile.type || 'account'], - ['Instance', instancePath], - ['Created', new Date(profile.created).toLocaleString()], - ['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'], - ['Sessions', `${sessionCount}`], - ]; - - console.log( - table(details, { - colWidths: [15, 45], - }) - ); - console.log(''); - } catch (error) { - exitWithError((error as Error).message, ExitCode.PROFILE_ERROR); - } + return handleShow(this.getContext(), args); } /** - * Remove a saved profile + * Remove profile - delegates to remove-command.ts */ async handleRemove(args: string[]): Promise { - await initUI(); - const { profileName, yes } = this.parseArgs(args); - - if (!profileName) { - console.log(fail('Profile name is required')); - console.log(''); - console.log(`Usage: ${color('ccs auth remove [--yes]', 'command')}`); - exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); - } - - // Check existence in both legacy and unified - const existsLegacy = this.registry.hasProfile(profileName); - const existsUnified = this.registry.hasAccountUnified(profileName); - - if (!existsLegacy && !existsUnified) { - console.log(fail(`Profile not found: ${profileName}`)); - exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); - } - - try { - // Get instance path and session count for impact display - const instancePath = this.instanceMgr.getInstancePath(profileName); - let sessionCount = 0; - - try { - const sessionsDir = path.join(instancePath, 'session-env'); - if (fs.existsSync(sessionsDir)) { - const files = fs.readdirSync(sessionsDir); - sessionCount = files.filter((f) => f.endsWith('.json')).length; - } - } catch (_e) { - // Ignore errors counting sessions - } - - // Display impact - console.log(''); - console.log(`Profile '${color(profileName, 'command')}' will be permanently deleted.`); - console.log(` Instance path: ${instancePath}`); - console.log(` Sessions: ${sessionCount} conversation${sessionCount !== 1 ? 's' : ''}`); - console.log(''); - - // Interactive confirmation (or --yes flag) - const confirmed = - yes || - (await InteractivePrompt.confirm( - 'Delete this profile?', - { default: false } // Default to NO (safe) - )); - - if (!confirmed) { - console.log(info('Cancelled')); - process.exit(0); - } - - // Delete instance - this.instanceMgr.deleteInstance(profileName); - - // Delete profile from appropriate config - if (isUnifiedMode() && existsUnified) { - this.registry.removeAccountUnified(profileName); - } - if (existsLegacy) { - this.registry.deleteProfile(profileName); - } - - console.log(ok(`Profile removed: ${profileName}`)); - console.log(''); - } catch (error) { - exitWithError( - `Failed to remove profile: ${(error as Error).message}`, - ExitCode.GENERAL_ERROR - ); - } + return handleRemove(this.getContext(), args); } /** - * Set default profile + * Set default profile - delegates to default-command.ts */ async handleDefault(args: string[]): Promise { - await initUI(); - const { profileName } = this.parseArgs(args); - - if (!profileName) { - console.log(fail('Profile name is required')); - console.log(''); - console.log(`Usage: ${color('ccs auth default ', 'command')}`); - exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); - } - - try { - // Use unified or legacy based on config mode - if (isUnifiedMode()) { - this.registry.setDefaultUnified(profileName); - } else { - this.registry.setDefaultProfile(profileName); - } - - console.log(ok(`Default profile set: ${profileName}`)); - console.log(''); - console.log('Now you can use:'); - console.log( - ` ${color('ccs "your prompt"', 'command')} ${dim(`# Uses ${profileName} profile`)}` - ); - console.log(''); - } catch (error) { - exitWithError((error as Error).message, ExitCode.PROFILE_ERROR); - } + return handleDefault(this.getContext(), args); } /** - * Reset default profile (clear the custom default, restore original CCS behavior) + * Reset default profile - delegates to default-command.ts */ async handleResetDefault(): Promise { - await initUI(); - - try { - // Use unified or legacy based on config mode - if (isUnifiedMode()) { - this.registry.clearDefaultUnified(); - } else { - this.registry.clearDefaultProfile(); - } - - console.log(ok('Default profile cleared')); - console.log(''); - console.log('CCS will now use the original behavior:'); - console.log(` ${dim('# Uses your primary Claude account')}`); - console.log(` ${color('ccs "your prompt"', 'command')}`); - console.log(''); - } catch (error) { - console.log(fail((error as Error).message)); - process.exit(1); - } + return handleResetDefault(this.getContext()); } /** diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts new file mode 100644 index 00000000..caeb41ed --- /dev/null +++ b/src/auth/commands/create-command.ts @@ -0,0 +1,148 @@ +/** + * Create Command Handler + * + * Creates a new profile and prompts for login in an isolated Claude instance. + */ + +import { spawn, ChildProcess } from 'child_process'; +import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../../utils/ui'; +import { getClaudeCliInfo } from '../../utils/claude-detector'; +import { escapeShellArg } from '../../utils/shell-executor'; +import { isUnifiedMode } from '../../config/unified-config-loader'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, parseArgs } from './types'; + +/** + * Handle the create command + */ +export async function handleCreate(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName, force } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log(`Usage: ${color('ccs auth create [--force]', 'command')}`); + console.log(''); + console.log('Example:'); + console.log(` ${color('ccs auth create work', 'command')}`); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + // Check if profile already exists (check both legacy and unified) + const existsLegacy = ctx.registry.hasProfile(profileName); + const existsUnified = ctx.registry.hasAccountUnified(profileName); + if (!force && (existsLegacy || existsUnified)) { + console.log(fail(`Profile already exists: ${profileName}`)); + console.log(` Use ${color('--force', 'command')} to overwrite`); + exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR); + } + + try { + // Create instance directory + console.log(info(`Creating profile: ${profileName}`)); + const instancePath = ctx.instanceMgr.ensureInstance(profileName); + + // Create/update profile entry based on config mode + if (isUnifiedMode()) { + // Use unified config (config.yaml) + if (existsUnified) { + ctx.registry.touchAccountUnified(profileName); + } else { + ctx.registry.createAccountUnified(profileName); + } + } else { + // Use legacy profiles.json + if (existsLegacy) { + ctx.registry.updateProfile(profileName, { + type: 'account', + }); + } else { + ctx.registry.createProfile(profileName, { + type: 'account', + }); + } + } + + console.log(info(`Instance directory: ${instancePath}`)); + console.log(''); + console.log(warn('Starting Claude in isolated instance...')); + console.log(warn('You will be prompted to login with your account.')); + console.log(''); + + // Detect Claude CLI + const claudeInfo = getClaudeCliInfo(); + if (!claudeInfo) { + console.log(fail('Claude CLI not found')); + console.log(''); + console.log('Please install Claude CLI first:'); + console.log(` ${color('https://claude.ai/download', 'path')}`); + exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR); + } + + const { path: claudeCli, needsShell } = claudeInfo; + + // Execute Claude in isolated instance (will auto-prompt for login if no credentials) + // On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly + let child: ChildProcess; + if (needsShell) { + const cmdString = escapeShellArg(claudeCli); + child = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: true, + env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath }, + }); + } else { + child = spawn(claudeCli, [], { + stdio: 'inherit', + windowsHide: true, + env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath }, + }); + } + + child.on('exit', (code: number | null) => { + if (code === 0) { + console.log(''); + console.log( + infoBox( + `Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`, + 'Profile Created' + ) + ); + console.log(''); + console.log(header('Usage')); + console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`); + console.log(''); + console.log( + warnBox( + `Running the command below will SWITCH your default\n` + + `CCS account to "${profileName}". After this, running\n` + + `"ccs" without a profile name will use this account.\n\n` + + ` ${color(`ccs auth default ${profileName}`, 'command')}\n\n` + + `To restore the original default, run:\n` + + ` ${color('ccs auth reset-default', 'command')}`, + 'Set as Default?' + ) + ); + console.log(''); + process.exit(0); + } else { + console.log(''); + console.log(fail('Login failed or cancelled')); + console.log(''); + console.log('To retry:'); + console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`); + console.log(''); + exitWithError('Login failed or cancelled', ExitCode.AUTH_ERROR); + } + }); + + child.on('error', (err: Error) => { + exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR); + }); + } catch (error) { + exitWithError(`Failed to create profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR); + } +} diff --git a/src/auth/commands/default-command.ts b/src/auth/commands/default-command.ts new file mode 100644 index 00000000..7964a60f --- /dev/null +++ b/src/auth/commands/default-command.ts @@ -0,0 +1,71 @@ +/** + * Default Command Handler + * + * Sets or clears the default profile. + */ + +import { initUI, color, dim, ok, fail } from '../../utils/ui'; +import { isUnifiedMode } from '../../config/unified-config-loader'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, parseArgs } from './types'; + +/** + * Handle the default command (set default profile) + */ +export async function handleDefault(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log(`Usage: ${color('ccs auth default ', 'command')}`); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + try { + // Use unified or legacy based on config mode + if (isUnifiedMode()) { + ctx.registry.setDefaultUnified(profileName); + } else { + ctx.registry.setDefaultProfile(profileName); + } + + console.log(ok(`Default profile set: ${profileName}`)); + console.log(''); + console.log('Now you can use:'); + console.log( + ` ${color('ccs "your prompt"', 'command')} ${dim(`# Uses ${profileName} profile`)}` + ); + console.log(''); + } catch (error) { + exitWithError((error as Error).message, ExitCode.PROFILE_ERROR); + } +} + +/** + * Handle the reset-default command (clear the custom default) + */ +export async function handleResetDefault(ctx: CommandContext): Promise { + await initUI(); + + try { + // Use unified or legacy based on config mode + if (isUnifiedMode()) { + ctx.registry.clearDefaultUnified(); + } else { + ctx.registry.clearDefaultProfile(); + } + + console.log(ok('Default profile cleared')); + console.log(''); + console.log('CCS will now use the original behavior:'); + console.log(` ${dim('# Uses your primary Claude account')}`); + console.log(` ${color('ccs "your prompt"', 'command')}`); + console.log(''); + } catch (error) { + console.log(fail((error as Error).message)); + process.exit(1); + } +} diff --git a/src/auth/commands/index.ts b/src/auth/commands/index.ts new file mode 100644 index 00000000..67a11be5 --- /dev/null +++ b/src/auth/commands/index.ts @@ -0,0 +1,22 @@ +/** + * Auth Commands Barrel Export + * + * Re-exports all command handlers and types from the commands module. + */ + +// Types and utilities +export { + AuthCommandArgs, + ProfileOutput, + ListOutput, + CommandContext, + parseArgs, + formatRelativeTime, +} from './types'; + +// Command handlers +export { handleCreate } from './create-command'; +export { handleList } from './list-command'; +export { handleShow } from './show-command'; +export { handleRemove } from './remove-command'; +export { handleDefault, handleResetDefault } from './default-command'; diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts new file mode 100644 index 00000000..90444c5e --- /dev/null +++ b/src/auth/commands/list-command.ts @@ -0,0 +1,138 @@ +/** + * List Command Handler + * + * Lists all saved profiles from both legacy and unified config. + */ + +import { ProfileMetadata } from '../../types'; +import { initUI, header, color, dim, warn, table } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types'; + +/** + * Handle the list command + */ +export async function handleList(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { verbose, json } = parseArgs(args); + + try { + // Get profiles from both legacy (profiles.json) and unified config (config.yaml) + const legacyProfiles = ctx.registry.getAllProfiles(); + const unifiedAccounts = ctx.registry.getAllAccountsUnified(); + + // Merge profiles: unified config takes precedence + const profiles: Record = { ...legacyProfiles }; + for (const [name, account] of Object.entries(unifiedAccounts)) { + profiles[name] = { + type: 'account', + created: account.created, + last_used: account.last_used, + }; + } + + const defaultProfile = ctx.registry.getDefaultUnified() ?? ctx.registry.getDefaultProfile(); + const profileNames = Object.keys(profiles); + + // JSON output mode + if (json) { + const output: ListOutput = { + version: ctx.version, + profiles: profileNames.map((name) => { + const profile = profiles[name]; + const isDefault = name === defaultProfile; + const instancePath = ctx.instanceMgr.getInstancePath(name); + + return { + name: name, + type: profile.type || 'account', + is_default: isDefault, + created: profile.created, + last_used: profile.last_used || null, + instance_path: instancePath, + }; + }), + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + // Human-readable output + if (profileNames.length === 0) { + console.log(warn('No account profiles found')); + console.log(''); + console.log('To create your first profile:'); + console.log(` ${color('ccs auth create ', 'command')}`); + console.log(''); + console.log('Example:'); + console.log(` ${color('ccs auth create work', 'command')}`); + console.log(''); + return; + } + + console.log(header('Saved Account Profiles')); + console.log(''); + + // Sort by last_used (descending), then alphabetically + const sorted = profileNames.sort((a, b) => { + const aProfile = profiles[a]; + const bProfile = profiles[b]; + + // Default first + if (a === defaultProfile) return -1; + if (b === defaultProfile) return 1; + + // Then by last_used + if (aProfile.last_used && bProfile.last_used) { + return new Date(bProfile.last_used).getTime() - new Date(aProfile.last_used).getTime(); + } + if (aProfile.last_used) return -1; + if (bProfile.last_used) return 1; + + // Then alphabetically + return a.localeCompare(b); + }); + + // Build table rows + const rows: string[][] = sorted.map((name) => { + const profile = profiles[name]; + const isDefault = name === defaultProfile; + + // Status column + const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success'); + + // Last used column + let lastUsed = '-'; + if (profile.last_used) { + lastUsed = formatRelativeTime(new Date(profile.last_used)); + } + + const row = [color(name, isDefault ? 'primary' : 'info'), profile.type || 'account', status]; + + if (verbose) { + row.push(lastUsed); + } + + return row; + }); + + // Headers + const headers = verbose + ? ['Profile', 'Type', 'Status', 'Last Used'] + : ['Profile', 'Type', 'Status']; + + // Print table + console.log( + table(rows, { + head: headers, + colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15], + }) + ); + console.log(''); + console.log(dim(`Total: ${profileNames.length} profile(s)`)); + console.log(''); + } catch (error) { + exitWithError(`Failed to list profiles: ${(error as Error).message}`, ExitCode.GENERAL_ERROR); + } +} diff --git a/src/auth/commands/remove-command.ts b/src/auth/commands/remove-command.ts new file mode 100644 index 00000000..cfef25cd --- /dev/null +++ b/src/auth/commands/remove-command.ts @@ -0,0 +1,86 @@ +/** + * Remove Command Handler + * + * Removes a saved profile and its instance directory. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { initUI, color, ok, fail, info } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import { isUnifiedMode } from '../../config/unified-config-loader'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, parseArgs } from './types'; + +/** + * Handle the remove command + */ +export async function handleRemove(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName, yes } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log(`Usage: ${color('ccs auth remove [--yes]', 'command')}`); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + // Check existence in both legacy and unified + const existsLegacy = ctx.registry.hasProfile(profileName); + const existsUnified = ctx.registry.hasAccountUnified(profileName); + + if (!existsLegacy && !existsUnified) { + console.log(fail(`Profile not found: ${profileName}`)); + exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); + } + + try { + // Get instance path and session count for impact display + const instancePath = ctx.instanceMgr.getInstancePath(profileName); + let sessionCount = 0; + + try { + const sessionsDir = path.join(instancePath, 'session-env'); + if (fs.existsSync(sessionsDir)) { + const files = fs.readdirSync(sessionsDir); + sessionCount = files.filter((f) => f.endsWith('.json')).length; + } + } catch (_e) { + // Ignore errors counting sessions + } + + // Display impact + console.log(''); + console.log(`Profile '${color(profileName, 'command')}' will be permanently deleted.`); + console.log(` Instance path: ${instancePath}`); + console.log(` Sessions: ${sessionCount} conversation${sessionCount !== 1 ? 's' : ''}`); + console.log(''); + + // Interactive confirmation (or --yes flag) + const confirmed = + yes || (await InteractivePrompt.confirm('Delete this profile?', { default: false })); // Default to NO (safe) + + if (!confirmed) { + console.log(info('Cancelled')); + process.exit(0); + } + + // Delete instance + ctx.instanceMgr.deleteInstance(profileName); + + // Delete profile from appropriate config + if (isUnifiedMode() && existsUnified) { + ctx.registry.removeAccountUnified(profileName); + } + if (existsLegacy) { + ctx.registry.deleteProfile(profileName); + } + + console.log(ok(`Profile removed: ${profileName}`)); + console.log(''); + } catch (error) { + exitWithError(`Failed to remove profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR); + } +} diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts new file mode 100644 index 00000000..7f0c5f1a --- /dev/null +++ b/src/auth/commands/show-command.ts @@ -0,0 +1,84 @@ +/** + * Show Command Handler + * + * Shows details for a specific profile. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { initUI, header, color, fail, table } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, ProfileOutput, parseArgs } from './types'; + +/** + * Handle the show command + */ +export async function handleShow(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName, json } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log(`Usage: ${color('ccs auth show [--json]', 'command')}`); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + try { + const profile = ctx.registry.getProfile(profileName); + const defaultProfile = ctx.registry.getDefaultProfile(); + const isDefault = profileName === defaultProfile; + const instancePath = ctx.instanceMgr.getInstancePath(profileName); + + // Count sessions + let sessionCount = 0; + try { + const sessionsDir = path.join(instancePath, 'session-env'); + if (fs.existsSync(sessionsDir)) { + const files = fs.readdirSync(sessionsDir); + sessionCount = files.filter((f) => f.endsWith('.json')).length; + } + } catch (_e) { + // Ignore errors counting sessions + } + + // JSON output mode + if (json) { + const output: ProfileOutput = { + name: profileName, + type: profile.type || 'account', + is_default: isDefault, + created: profile.created, + last_used: profile.last_used || null, + instance_path: instancePath, + session_count: sessionCount, + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + // Human-readable output + const defaultBadge = isDefault ? color(' (default)', 'success') : ''; + console.log(header(`Profile: ${profileName}${defaultBadge}`)); + console.log(''); + + // Details table + const details = [ + ['Type', profile.type || 'account'], + ['Instance', instancePath], + ['Created', new Date(profile.created).toLocaleString()], + ['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'], + ['Sessions', `${sessionCount}`], + ]; + + console.log( + table(details, { + colWidths: [15, 45], + }) + ); + console.log(''); + } catch (error) { + exitWithError((error as Error).message, ExitCode.PROFILE_ERROR); + } +} diff --git a/src/auth/commands/types.ts b/src/auth/commands/types.ts new file mode 100644 index 00000000..1bb13116 --- /dev/null +++ b/src/auth/commands/types.ts @@ -0,0 +1,80 @@ +/** + * Auth Commands Type Definitions + * + * Shared interfaces for auth command modules. + */ + +import ProfileRegistry from '../profile-registry'; +import { InstanceManager } from '../../management/instance-manager'; + +/** + * Command arguments parsed from CLI + */ +export interface AuthCommandArgs { + profileName?: string; + force?: boolean; + verbose?: boolean; + json?: boolean; + yes?: boolean; +} + +/** + * Profile output for JSON mode + */ +export interface ProfileOutput { + name: string; + type: string; + is_default: boolean; + created: string; + last_used: string | null; + instance_path?: string; + session_count?: number; +} + +/** + * List output for JSON mode + */ +export interface ListOutput { + version: string; + profiles: ProfileOutput[]; +} + +/** + * Shared context passed to command handlers + */ +export interface CommandContext { + registry: ProfileRegistry; + instanceMgr: InstanceManager; + version: string; +} + +/** + * Parse command arguments from raw args array + */ +export function parseArgs(args: string[]): AuthCommandArgs { + const profileName = args.find((arg) => !arg.startsWith('--')); + return { + profileName, + force: args.includes('--force'), + verbose: args.includes('--verbose'), + json: args.includes('--json'), + yes: args.includes('--yes') || args.includes('-y'), + }; +} + +/** + * Format relative time (e.g., "2h ago", "1d ago") + */ +export function formatRelativeTime(date: Date): string { + const now = Date.now(); + const diff = now - date.getTime(); + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return 'just now'; +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 00000000..c7298e77 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,8 @@ +/** + * Auth module barrel export + */ + +export { default as AuthCommands } from './auth-commands'; +export { default as ProfileRegistry } from './profile-registry'; +export { default as ProfileDetector } from './profile-detector'; +export type { ProfileDetectionResult, ProfileType, AllProfiles } from './profile-detector';