import * as fs from 'fs'; import * as path from 'path'; import { ProfileMetadata } from '../types'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig, isUnifiedMode, } from '../config/unified-config-loader'; import type { AccountConfig } from '../config/unified-config-types'; import { getCcsDir } from '../utils/config-manager'; /** * Profile Registry (Simplified) * * Manages account profile metadata in ~/.ccs/profiles.json * Each profile represents an isolated Claude instance with login credentials. * * Profile Schema (v3.0 - Minimal): * { * type: 'account', // Profile type * created: , // Creation time * last_used: // Last usage time * context_mode?: 'isolated' | 'shared' // Workspace context policy * context_group?: // Shared context group when mode=shared * } * * Removed fields from v2.x: * - vault: No encrypted vault (credentials in instance) * - subscription: No credential reading * - email: No credential reading */ interface ProfileData { version: string; profiles: Record; default: string | null; } interface CreateMetadata { type?: string; created?: string; last_used?: string | null; context_mode?: 'isolated' | 'shared'; context_group?: string; } export class ProfileRegistry { private profilesPath: string; constructor() { this.profilesPath = path.join(getCcsDir(), 'profiles.json'); } private normalizeLegacyProfileMetadata(metadata: ProfileMetadata): ProfileMetadata { const normalized: ProfileMetadata = { ...metadata }; if (normalized.context_mode !== 'shared') { delete normalized.context_group; } else if (!normalized.context_group || normalized.context_group.trim().length === 0) { delete normalized.context_group; } return normalized; } private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig { const normalized: AccountConfig = { ...account }; if (normalized.context_mode !== 'shared') { delete normalized.context_group; } else if (!normalized.context_group || normalized.context_group.trim().length === 0) { delete normalized.context_group; } return normalized; } /** * Read profiles from disk */ private _read(): ProfileData { if (!fs.existsSync(this.profilesPath)) { return { version: '2.0.0', profiles: {}, default: null, }; } try { const data = fs.readFileSync(this.profilesPath, 'utf8'); return JSON.parse(data) as ProfileData; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to read profiles: ${message}`); } } /** * Write profiles to disk atomically */ private _write(data: ProfileData): void { const dir = path.dirname(this.profilesPath); // Ensure directory exists if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } // Atomic write: temp file + rename const tempPath = `${this.profilesPath}.tmp`; try { fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), { mode: 0o600 }); fs.renameSync(tempPath, this.profilesPath); } catch (error) { // Cleanup temp file on error if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to write profiles: ${message}`); } } /** * Create a new profile */ createProfile(name: string, metadata: CreateMetadata = {}): void { const data = this._read(); if (data.profiles[name]) { throw new Error(`Profile already exists: ${name}`); } // v3.0 minimal schema: only essential fields data.profiles[name] = this.normalizeLegacyProfileMetadata({ type: metadata.type || 'account', created: metadata.created || new Date().toISOString(), last_used: metadata.last_used || null, context_mode: metadata.context_mode, context_group: metadata.context_group, }); // Note: No longer auto-set as default // Users must explicitly run: ccs auth default // Default always stays on implicit 'default' profile (uses ~/.claude/) this._write(data); } /** * Get profile metadata */ getProfile(name: string): ProfileMetadata { const data = this._read(); if (!data.profiles[name]) { throw new Error(`Profile not found: ${name}`); } return this.normalizeLegacyProfileMetadata(data.profiles[name]); } /** * Update profile metadata */ updateProfile(name: string, updates: Partial): void { const data = this._read(); if (!data.profiles[name]) { throw new Error(`Profile not found: ${name}`); } data.profiles[name] = this.normalizeLegacyProfileMetadata({ ...data.profiles[name], ...updates, }); this._write(data); } /** * Delete a profile */ deleteProfile(name: string): void { const data = this._read(); if (!data.profiles[name]) { throw new Error(`Profile not found: ${name}`); } delete data.profiles[name]; // Clear default if it was the deleted profile if (data.default === name) { // Set to first remaining profile or null const remaining = Object.keys(data.profiles); data.default = remaining.length > 0 ? remaining[0] : null; } this._write(data); } /** * List all profiles */ listProfiles(): string[] { const data = this._read(); return Object.keys(data.profiles); } /** * Get all profiles with metadata */ getAllProfiles(): Record { const data = this._read(); const normalized: Record = {}; for (const [name, profile] of Object.entries(data.profiles)) { normalized[name] = this.normalizeLegacyProfileMetadata(profile); } return normalized; } /** * Get default profile name */ getDefaultProfile(): string | null { const data = this._read(); return data.default; } /** * Set default profile */ setDefaultProfile(name: string): void { const data = this._read(); if (!data.profiles[name]) { throw new Error(`Profile not found: ${name}`); } data.default = name; this._write(data); } /** * Clear default profile (restore original CCS behavior) */ clearDefaultProfile(): void { const data = this._read(); data.default = null; this._write(data); } /** * Check if profile exists */ hasProfile(name: string): boolean { const data = this._read(); return !!data.profiles[name]; } /** * Update last used timestamp */ touchProfile(name: string): void { this.updateProfile(name, { last_used: new Date().toISOString(), }); } // ========================================== // Unified Config Methods // ========================================== /** * Create account in unified config (config.yaml) */ createAccountUnified(name: string, metadata: CreateMetadata = {}): void { const config = loadOrCreateUnifiedConfig(); if (config.accounts[name]) { throw new Error(`Account already exists: ${name}`); } config.accounts[name] = this.normalizeUnifiedAccountConfig({ created: new Date().toISOString(), last_used: null, context_mode: metadata.context_mode, context_group: metadata.context_group, }); saveUnifiedConfig(config); } /** * Update account metadata in unified config */ updateAccountUnified(name: string, updates: Partial): void { const config = loadOrCreateUnifiedConfig(); if (!config.accounts[name]) { throw new Error(`Account not found: ${name}`); } config.accounts[name] = this.normalizeUnifiedAccountConfig({ ...config.accounts[name], ...updates, }); saveUnifiedConfig(config); } /** * Remove account from unified config */ removeAccountUnified(name: string): void { const config = loadOrCreateUnifiedConfig(); if (!config.accounts[name]) { throw new Error(`Account not found: ${name}`); } delete config.accounts[name]; // Clear default if it was the deleted account if (config.default === name) { config.default = undefined; } saveUnifiedConfig(config); } /** * Set default profile in unified config */ setDefaultUnified(name: string): void { const config = loadOrCreateUnifiedConfig(); // Check if exists in accounts, profiles, or cliproxy variants const exists = config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name]; if (!exists) { throw new Error(`Profile not found: ${name}`); } config.default = name; saveUnifiedConfig(config); } /** * Clear default profile in unified config (restore original CCS behavior) */ clearDefaultUnified(): void { const config = loadOrCreateUnifiedConfig(); config.default = undefined; saveUnifiedConfig(config); } /** * Check if account exists in unified config */ hasAccountUnified(name: string): boolean { if (!isUnifiedMode()) return false; const config = loadOrCreateUnifiedConfig(); return !!config.accounts[name]; } /** * Get all accounts from unified config */ getAllAccountsUnified(): Record { if (!isUnifiedMode()) return {}; const config = loadOrCreateUnifiedConfig(); const normalized: Record = {}; for (const [name, account] of Object.entries(config.accounts)) { normalized[name] = this.normalizeUnifiedAccountConfig(account); } return normalized; } /** * Get default from unified config */ getDefaultUnified(): string | undefined { if (!isUnifiedMode()) return undefined; const config = loadOrCreateUnifiedConfig(); return config.default; } /** * Update account last_used in unified config */ touchAccountUnified(name: string): void { const config = loadOrCreateUnifiedConfig(); if (!config.accounts[name]) { throw new Error(`Account not found: ${name}`); } config.accounts[name].last_used = new Date().toISOString(); config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]); saveUnifiedConfig(config); } // ========================================== // DRY Helper Methods (consolidated logic) // ========================================== /** * Get all profiles merged from both legacy and unified config. * Unified config takes precedence for duplicate names. * DRY helper to consolidate merge logic used in multiple places. */ getAllProfilesMerged(): Record { const legacyProfiles = this.getAllProfiles(); const unifiedAccounts = this.getAllAccountsUnified(); // Start with legacy profiles const merged: Record = { ...legacyProfiles }; // Override with unified config accounts (takes precedence) for (const [name, account] of Object.entries(unifiedAccounts)) { merged[name] = { type: 'account', created: account.created, last_used: account.last_used, context_mode: account.context_mode, context_group: account.context_group, }; } return merged; } /** * Get resolved default profile from unified config first, fallback to legacy. * DRY helper to consolidate default resolution logic. */ getDefaultResolved(): string | null { return this.getDefaultUnified() ?? this.getDefaultProfile(); } } export default ProfileRegistry;