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
This commit is contained in:
kaitranntt
2025-12-19 12:36:24 -05:00
parent c3baaa8251
commit 0341f4f86f
9 changed files with 679 additions and 532 deletions
+42 -532
View File
@@ -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<void> {
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 <profile> [--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<void> {
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<string, ProfileMetadata> = { ...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 <profile>', '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<void> {
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 <profile> [--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<void> {
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 <profile> [--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<void> {
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 <profile>', '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<void> {
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());
}
/**
+148
View File
@@ -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<void> {
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 <profile> [--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);
}
}
+71
View File
@@ -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<void> {
await initUI();
const { profileName } = parseArgs(args);
if (!profileName) {
console.log(fail('Profile name is required'));
console.log('');
console.log(`Usage: ${color('ccs auth default <profile>', '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<void> {
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);
}
}
+22
View File
@@ -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';
+138
View File
@@ -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<void> {
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<string, ProfileMetadata> = { ...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 <profile>', '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);
}
}
+86
View File
@@ -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<void> {
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 <profile> [--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);
}
}
+84
View File
@@ -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<void> {
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 <profile> [--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);
}
}
+80
View File
@@ -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';
}
+8
View File
@@ -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';