mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
468 lines
13 KiB
TypeScript
468 lines
13 KiB
TypeScript
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';
|
|
import { isValidContextGroupName, normalizeContextGroupName } from './account-context';
|
|
|
|
/**
|
|
* 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: <ISO timestamp>, // Creation time
|
|
* last_used: <ISO timestamp or null> // Last usage time
|
|
* context_mode?: 'isolated' | 'shared' // Workspace context policy
|
|
* context_group?: <string> // Shared context group when mode=shared
|
|
* continuity_mode?: 'standard' | 'deeper' // Shared continuity depth
|
|
* }
|
|
*
|
|
* 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<string, ProfileMetadata>;
|
|
default: string | null;
|
|
}
|
|
|
|
interface CreateMetadata {
|
|
type?: string;
|
|
created?: string;
|
|
last_used?: string | null;
|
|
context_mode?: 'isolated' | 'shared';
|
|
context_group?: string;
|
|
continuity_mode?: 'standard' | 'deeper';
|
|
}
|
|
|
|
export class ProfileRegistry {
|
|
private profilesPath: string;
|
|
|
|
constructor() {
|
|
this.profilesPath = path.join(getCcsDir(), 'profiles.json');
|
|
}
|
|
|
|
private normalizeContextGroupValue(value: unknown): string | undefined {
|
|
if (typeof value !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const normalized = normalizeContextGroupName(value);
|
|
if (normalized.length === 0 || !isValidContextGroupName(normalized)) {
|
|
return undefined;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
private normalizeLegacyProfileMetadata(metadata: ProfileMetadata): ProfileMetadata {
|
|
const normalized: ProfileMetadata = { ...metadata };
|
|
|
|
if (normalized.context_mode !== 'shared') {
|
|
delete normalized.context_group;
|
|
delete normalized.continuity_mode;
|
|
} else {
|
|
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
|
|
if (normalizedGroup) {
|
|
normalized.context_group = normalizedGroup;
|
|
} else {
|
|
delete normalized.context_group;
|
|
}
|
|
|
|
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig {
|
|
const normalized: AccountConfig = { ...account };
|
|
|
|
if (normalized.context_mode !== 'shared') {
|
|
delete normalized.context_group;
|
|
delete normalized.continuity_mode;
|
|
} else {
|
|
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
|
|
if (normalizedGroup) {
|
|
normalized.context_group = normalizedGroup;
|
|
} else {
|
|
delete normalized.context_group;
|
|
}
|
|
|
|
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
|
}
|
|
|
|
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,
|
|
continuity_mode: metadata.continuity_mode,
|
|
});
|
|
|
|
// Note: No longer auto-set as default
|
|
// Users must explicitly run: ccs auth default <profile>
|
|
// 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<ProfileMetadata>): 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<string, ProfileMetadata> {
|
|
const data = this._read();
|
|
const normalized: Record<string, ProfileMetadata> = {};
|
|
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,
|
|
continuity_mode: metadata.continuity_mode,
|
|
});
|
|
saveUnifiedConfig(config);
|
|
}
|
|
|
|
/**
|
|
* Update account metadata in unified config
|
|
*/
|
|
updateAccountUnified(name: string, updates: Partial<AccountConfig>): 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<string, AccountConfig> {
|
|
if (!isUnifiedMode()) return {};
|
|
const config = loadOrCreateUnifiedConfig();
|
|
const normalized: Record<string, AccountConfig> = {};
|
|
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<string, ProfileMetadata> {
|
|
const legacyProfiles = this.getAllProfiles();
|
|
const unifiedAccounts = this.getAllAccountsUnified();
|
|
|
|
// Start with legacy profiles
|
|
const merged: Record<string, ProfileMetadata> = { ...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,
|
|
continuity_mode: account.continuity_mode,
|
|
};
|
|
}
|
|
|
|
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;
|