mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
- add runtime-aware resume lane diagnostics and auth backup flows - warn when account resume uses a different plain ccs continuity lane - surface lane mismatch guidance in the accounts dashboard, docs, and tests
619 lines
20 KiB
TypeScript
619 lines
20 KiB
TypeScript
/**
|
|
* Profile Detector
|
|
*
|
|
* Determines profile type (settings-based vs account-based) for routing.
|
|
* Priority: settings-based profiles (glm/km) checked FIRST for backward compatibility.
|
|
*
|
|
* Supports dual-mode configuration:
|
|
* - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists
|
|
* - Legacy JSON format (config.json, profiles.json) as fallback
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { findSimilarStrings, expandPath } from '../utils/helpers';
|
|
import { Config, Settings, ProfileMetadata } from '../types';
|
|
import {
|
|
UnifiedConfig,
|
|
CopilotConfig,
|
|
CursorConfig,
|
|
CLIProxyVariantConfig,
|
|
CompositeVariantConfig,
|
|
CompositeTierConfig,
|
|
} from '../config/unified-config-types';
|
|
import { loadUnifiedConfig, isUnifiedMode, getCursorConfig } from '../config/unified-config-loader';
|
|
import { getCcsDir } from '../utils/config-manager';
|
|
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
|
|
import type { CLIProxyProvider } from '../cliproxy/types';
|
|
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
|
|
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
|
|
import type { TargetType } from '../targets/target-adapter';
|
|
import type { ProfileType } from '../types/profile';
|
|
export type { ProfileType } from '../types/profile';
|
|
|
|
/** CLIProxy profile names (OAuth-based, zero config) */
|
|
export const CLIPROXY_PROFILES: readonly CLIProxyProvider[] = CLIPROXY_PROVIDER_IDS;
|
|
export type CLIProxyProfileName = CLIProxyProvider;
|
|
|
|
export interface ProfileDetectionResult {
|
|
type: ProfileType;
|
|
name: string;
|
|
target?: TargetType;
|
|
settingsPath?: string;
|
|
profile?: Settings | ProfileMetadata;
|
|
message?: string;
|
|
/** For cliproxy variants: the underlying provider (gemini, codex, agy, qwen) */
|
|
provider?: CLIProxyProfileName;
|
|
/** For cliproxy variants: dedicated port for isolation (8318-8417) */
|
|
port?: number;
|
|
/** For unified config profiles: merged env vars (config + secrets) */
|
|
env?: Record<string, string>;
|
|
/** For copilot profile: the copilot config */
|
|
copilotConfig?: CopilotConfig;
|
|
/** For cursor profile: the cursor config */
|
|
cursorConfig?: CursorConfig;
|
|
/** For composite variants: true when variant mixes providers per tier */
|
|
isComposite?: boolean;
|
|
/** For composite variants: which tier is the default */
|
|
compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku';
|
|
/** For composite variants: per-tier provider+model mappings */
|
|
compositeTiers?: {
|
|
opus: CompositeTierConfig;
|
|
sonnet: CompositeTierConfig;
|
|
haiku: CompositeTierConfig;
|
|
};
|
|
}
|
|
|
|
export interface AllProfiles {
|
|
settings: string[];
|
|
accounts: string[];
|
|
default?: string;
|
|
}
|
|
|
|
export interface ProfileNotFoundError extends Error {
|
|
profileName: string;
|
|
suggestions: string[];
|
|
availableProfiles: string;
|
|
}
|
|
|
|
/**
|
|
* Profile Detector Class
|
|
*/
|
|
/**
|
|
* Load env vars from a settings file (*.settings.json).
|
|
* Uses expandPath() for consistent cross-platform path handling.
|
|
* Returns empty object on error.
|
|
*/
|
|
export function loadSettingsFromFile(settingsPath: string): Record<string, string> {
|
|
const expandedPath = expandPath(settingsPath);
|
|
try {
|
|
if (!fs.existsSync(expandedPath)) return {};
|
|
const content = fs.readFileSync(expandedPath, 'utf8');
|
|
const settings = JSON.parse(content) as { env?: Record<string, string> };
|
|
return settings.env || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
class ProfileDetector {
|
|
private readonly configPath: string;
|
|
private readonly profilesPath: string;
|
|
|
|
constructor() {
|
|
const ccsDir = getCcsDir();
|
|
this.configPath = path.join(ccsDir, 'config.json');
|
|
this.profilesPath = path.join(ccsDir, 'profiles.json');
|
|
}
|
|
|
|
/**
|
|
* Load unified config if available.
|
|
* Returns null if not in unified mode or load fails.
|
|
*/
|
|
private readUnifiedConfig(): UnifiedConfig | null {
|
|
if (!isUnifiedMode()) return null;
|
|
return loadUnifiedConfig();
|
|
}
|
|
|
|
/**
|
|
* Resolve profile from unified config format.
|
|
* Returns null if profile not found in unified config.
|
|
*/
|
|
private resolveFromUnifiedConfig(
|
|
profileName: string,
|
|
config: UnifiedConfig
|
|
): ProfileDetectionResult | null {
|
|
// Check CLIProxy variants first
|
|
if (config.cliproxy?.variants?.[profileName]) {
|
|
const variant = config.cliproxy.variants[profileName];
|
|
|
|
// Handle composite variants
|
|
if ('type' in variant && variant.type === 'composite') {
|
|
const composite = variant as CompositeVariantConfig;
|
|
|
|
// Defensive: check for missing tiers or default_tier
|
|
if (!composite.tiers || !composite.default_tier) {
|
|
console.warn(
|
|
`[!] Warning: Composite variant '${profileName}' has missing tiers or default_tier`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const defaultTierConfig = composite.tiers[composite.default_tier];
|
|
if (!defaultTierConfig) {
|
|
console.warn(
|
|
`[!] Warning: Composite variant '${profileName}' missing config for default tier '${composite.default_tier}'`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
type: 'cliproxy',
|
|
name: profileName,
|
|
target: composite.target,
|
|
provider: defaultTierConfig.provider as CLIProxyProfileName,
|
|
settingsPath: composite.settings,
|
|
port: composite.port,
|
|
isComposite: true,
|
|
compositeDefaultTier: composite.default_tier,
|
|
compositeTiers: composite.tiers,
|
|
};
|
|
}
|
|
|
|
const singleVariant = variant as CLIProxyVariantConfig;
|
|
return {
|
|
type: 'cliproxy',
|
|
name: profileName,
|
|
target: singleVariant.target,
|
|
provider: singleVariant.provider as CLIProxyProfileName,
|
|
settingsPath: singleVariant.settings,
|
|
port: singleVariant.port,
|
|
};
|
|
}
|
|
|
|
// Check API profiles (supports compatibility aliases, e.g. km -> kimi)
|
|
for (const candidate of getProfileLookupCandidates(profileName)) {
|
|
if (!config.profiles?.[candidate]) {
|
|
continue;
|
|
}
|
|
|
|
const profile = config.profiles[candidate];
|
|
const settingsPath = profile.settings;
|
|
const settingsEnv = loadSettingsFromFile(settingsPath);
|
|
const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate);
|
|
|
|
return {
|
|
type: 'settings',
|
|
name: profileName,
|
|
target: profile.target,
|
|
settingsPath,
|
|
env: settingsEnv,
|
|
message: viaLegacyAlias
|
|
? `Using legacy API profile "${candidate}" for "${profileName}".`
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
// Check accounts
|
|
if (config.accounts?.[profileName]) {
|
|
const account = config.accounts[profileName];
|
|
return {
|
|
type: 'account',
|
|
name: profileName,
|
|
profile: {
|
|
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,
|
|
bare: account.bare,
|
|
},
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Read settings-based config (config.json)
|
|
*/
|
|
private readConfig(): Config {
|
|
if (!fs.existsSync(this.configPath)) {
|
|
return { profiles: {} };
|
|
}
|
|
|
|
try {
|
|
const data = fs.readFileSync(this.configPath, 'utf8');
|
|
return JSON.parse(data) as Config;
|
|
} catch (error) {
|
|
console.warn(`[!] Warning: Could not read config.json: ${(error as Error).message}`);
|
|
return { profiles: {} };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read account-based profiles (profiles.json)
|
|
*/
|
|
private readProfiles(): { profiles: Record<string, ProfileMetadata>; default?: string } {
|
|
if (!fs.existsSync(this.profilesPath)) {
|
|
return { profiles: {}, default: undefined };
|
|
}
|
|
|
|
try {
|
|
const data = fs.readFileSync(this.profilesPath, 'utf8');
|
|
return JSON.parse(data);
|
|
} catch (error) {
|
|
console.warn(`[!] Warning: Could not read profiles.json: ${(error as Error).message}`);
|
|
return { profiles: {}, default: undefined };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect profile type and return routing information
|
|
*
|
|
* Priority order:
|
|
* 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen)
|
|
* 0.5. Copilot profile (if enabled in config)
|
|
* 0.75. Cursor profile (if enabled in config)
|
|
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
|
|
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
|
|
* 3. Settings-based profiles (config.profiles section) [legacy]
|
|
* 4. Account-based profiles (profiles.json) [legacy]
|
|
*/
|
|
detectProfileType(profileName: string | null | undefined): ProfileDetectionResult {
|
|
// Special case: 'default' means use default profile
|
|
if (profileName === 'default' || profileName === null || profileName === undefined) {
|
|
return this.resolveDefaultProfile();
|
|
}
|
|
|
|
// Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config
|
|
if (isCLIProxyProvider(profileName)) {
|
|
return {
|
|
type: 'cliproxy',
|
|
name: profileName,
|
|
provider: profileName,
|
|
};
|
|
}
|
|
|
|
// Priority 0.5: Check Copilot profile - GitHub Copilot subscription via copilot-api
|
|
if (profileName === 'copilot') {
|
|
const unifiedConfig = this.readUnifiedConfig();
|
|
const copilotConfig = unifiedConfig?.copilot;
|
|
|
|
if (!copilotConfig?.enabled) {
|
|
const error = new Error(
|
|
'Copilot profile is not enabled.\n\n' +
|
|
'To enable GitHub Copilot integration:\n' +
|
|
' 1. Run: ccs config\n' +
|
|
' 2. Go to "GitHub Copilot" section\n' +
|
|
' 3. Enable the integration\n' +
|
|
' 4. Authenticate with GitHub: npx copilot-api auth\n\n' +
|
|
'Or manually edit ~/.ccs/config.yaml:\n' +
|
|
' copilot:\n' +
|
|
' enabled: true'
|
|
) as ProfileNotFoundError;
|
|
error.profileName = profileName;
|
|
error.suggestions = [];
|
|
error.availableProfiles = this.listAvailableProfiles();
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
type: 'copilot',
|
|
name: 'copilot',
|
|
copilotConfig,
|
|
};
|
|
}
|
|
|
|
// Priority 0.75: Check Cursor profile - local Cursor daemon runtime
|
|
if (profileName === 'cursor') {
|
|
const cursorConfig = getCursorConfig();
|
|
|
|
if (!cursorConfig?.enabled) {
|
|
const error = new Error(
|
|
'Cursor profile is not enabled.\n\n' +
|
|
'To enable Cursor integration:\n' +
|
|
' 1. Run: ccs cursor enable\n' +
|
|
' 2. Import auth: ccs cursor auth\n' +
|
|
' 3. Start daemon: ccs cursor start\n\n' +
|
|
'Or manually edit ~/.ccs/config.yaml:\n' +
|
|
' cursor:\n' +
|
|
' enabled: true'
|
|
) as ProfileNotFoundError;
|
|
error.profileName = profileName;
|
|
error.suggestions = [];
|
|
error.availableProfiles = this.listAvailableProfiles();
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
type: 'cursor',
|
|
name: 'cursor',
|
|
cursorConfig,
|
|
};
|
|
}
|
|
|
|
// Priority 1: Try unified config if available
|
|
const unifiedConfig = this.readUnifiedConfig();
|
|
if (unifiedConfig) {
|
|
const result = this.resolveFromUnifiedConfig(profileName, unifiedConfig);
|
|
if (result) return result;
|
|
// Fall through to legacy if not found in unified config
|
|
}
|
|
|
|
// Priority 2: Check user-defined CLIProxy variants (config.cliproxy section)
|
|
const config = this.readConfig();
|
|
const legacyTargetMap = (config as { profile_targets?: Record<string, TargetType> })
|
|
.profile_targets;
|
|
|
|
if (config.cliproxy && config.cliproxy[profileName]) {
|
|
const variant = config.cliproxy[profileName];
|
|
return {
|
|
type: 'cliproxy',
|
|
name: profileName,
|
|
target: variant.target,
|
|
provider: variant.provider as CLIProxyProfileName,
|
|
settingsPath: variant.settings,
|
|
port: variant.port,
|
|
};
|
|
}
|
|
|
|
// Priority 3: Check settings-based profiles (glm, km) - LEGACY FALLBACK
|
|
if (config.profiles) {
|
|
for (const candidate of getProfileLookupCandidates(profileName)) {
|
|
if (!config.profiles[candidate]) {
|
|
continue;
|
|
}
|
|
|
|
const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate);
|
|
return {
|
|
type: 'settings',
|
|
name: profileName,
|
|
settingsPath: config.profiles[candidate],
|
|
target: legacyTargetMap?.[candidate],
|
|
message: viaLegacyAlias
|
|
? `Using legacy API profile "${candidate}" for "${profileName}".`
|
|
: undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK
|
|
const profiles = this.readProfiles();
|
|
|
|
if (profiles.profiles && profiles.profiles[profileName]) {
|
|
return {
|
|
type: 'account',
|
|
name: profileName,
|
|
profile: profiles.profiles[profileName],
|
|
};
|
|
}
|
|
|
|
// Not found - generate suggestions
|
|
const allProfiles = this.getAllProfiles();
|
|
const allProfileNames = [
|
|
...allProfiles.cliproxy,
|
|
...allProfiles.cliproxyVariants,
|
|
...allProfiles.settings,
|
|
...allProfiles.accounts,
|
|
];
|
|
const suggestions = findSimilarStrings(profileName, allProfileNames);
|
|
|
|
const error = new Error(`Profile not found: ${profileName}`) as ProfileNotFoundError;
|
|
error.profileName = profileName;
|
|
error.suggestions = suggestions;
|
|
error.availableProfiles = this.listAvailableProfiles();
|
|
throw error;
|
|
}
|
|
|
|
/**
|
|
* Resolve default profile
|
|
*/
|
|
private resolveDefaultProfile(): ProfileDetectionResult {
|
|
// Check unified config first
|
|
const unifiedConfig = this.readUnifiedConfig();
|
|
if (unifiedConfig?.default) {
|
|
const result = this.resolveFromUnifiedConfig(unifiedConfig.default, unifiedConfig);
|
|
if (result) return result;
|
|
}
|
|
|
|
// Check if account-based default exists (legacy)
|
|
const profiles = this.readProfiles();
|
|
|
|
if (profiles.default && profiles.profiles[profiles.default]) {
|
|
return {
|
|
type: 'account',
|
|
name: profiles.default,
|
|
profile: profiles.profiles[profiles.default],
|
|
};
|
|
}
|
|
|
|
// Check if settings-based default exists
|
|
const config = this.readConfig();
|
|
const legacyTargetMap = (config as { profile_targets?: Record<string, TargetType> })
|
|
.profile_targets;
|
|
|
|
if (config.profiles && config.profiles['default']) {
|
|
const settingsPath = config.profiles['default'];
|
|
// Safety net: If default points to ~/.claude/settings.json, treat as pass-through
|
|
// to avoid loading stale env vars from previous profile sessions (issue #37).
|
|
// The ~/.claude/settings.json is Claude's native config - let Claude handle it.
|
|
if (settingsPath.includes('.claude') && settingsPath.endsWith('settings.json')) {
|
|
return {
|
|
type: 'default',
|
|
name: 'default',
|
|
message: 'Using native Claude auth (no custom env vars)',
|
|
};
|
|
}
|
|
return {
|
|
type: 'settings',
|
|
name: 'default',
|
|
settingsPath,
|
|
target: legacyTargetMap?.['default'],
|
|
};
|
|
}
|
|
|
|
// No default profile configured, use Claude's own defaults
|
|
return {
|
|
type: 'default',
|
|
name: 'default',
|
|
message: 'No profile configured. Using Claude CLI defaults from ~/.claude/',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Public wrapper for diagnostics and tooling that need to inspect how
|
|
* plain `ccs` resolves without duplicating the default-profile logic.
|
|
*/
|
|
resolveDefaultProfileResult(): ProfileDetectionResult {
|
|
return this.resolveDefaultProfile();
|
|
}
|
|
|
|
/**
|
|
* List available profiles (for error messages)
|
|
*/
|
|
private listAvailableProfiles(): string {
|
|
const lines: string[] = [];
|
|
|
|
// CLIProxy profiles (OAuth-based, always available)
|
|
lines.push('CLIProxy profiles (OAuth, zero config):');
|
|
CLIPROXY_PROFILES.forEach((name) => {
|
|
lines.push(` - ${name}`);
|
|
});
|
|
|
|
// Check unified config first
|
|
const unifiedConfig = this.readUnifiedConfig();
|
|
if (unifiedConfig) {
|
|
// Copilot profile (if enabled)
|
|
if (unifiedConfig.copilot?.enabled) {
|
|
const currentCopilotModel = normalizeCopilotModelId(unifiedConfig.copilot.model);
|
|
lines.push('GitHub Copilot (via copilot-api):');
|
|
lines.push(` - copilot (model: ${currentCopilotModel})`);
|
|
}
|
|
|
|
if (unifiedConfig.cursor?.enabled) {
|
|
lines.push('Cursor local proxy:');
|
|
lines.push(` - cursor (model: ${unifiedConfig.cursor.model})`);
|
|
}
|
|
|
|
// CLIProxy variants from unified config
|
|
const variants = Object.keys(unifiedConfig.cliproxy?.variants || {});
|
|
if (variants.length > 0) {
|
|
lines.push('CLIProxy variants (unified config):');
|
|
variants.forEach((name) => {
|
|
const variant = unifiedConfig.cliproxy?.variants[name];
|
|
const label =
|
|
variant && 'type' in variant && variant.type === 'composite'
|
|
? 'composite'
|
|
: (variant as { provider?: string })?.provider || 'unknown';
|
|
lines.push(` - ${name} (${label})`);
|
|
});
|
|
}
|
|
|
|
// API profiles from unified config
|
|
const apiProfiles = Object.keys(unifiedConfig.profiles || {});
|
|
if (apiProfiles.length > 0) {
|
|
lines.push('API profiles (unified config):');
|
|
apiProfiles.forEach((name) => {
|
|
const isDefault = name === unifiedConfig.default;
|
|
lines.push(` - ${name}${isDefault ? ' [DEFAULT]' : ''}`);
|
|
});
|
|
}
|
|
|
|
// Account profiles from unified config
|
|
const accounts = Object.keys(unifiedConfig.accounts || {});
|
|
if (accounts.length > 0) {
|
|
lines.push('Account profiles (unified config):');
|
|
accounts.forEach((name) => {
|
|
const isDefault = name === unifiedConfig.default;
|
|
lines.push(` - ${name}${isDefault ? ' [DEFAULT]' : ''}`);
|
|
});
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// Fall back to legacy config display
|
|
// CLIProxy variants (user-defined)
|
|
const config = this.readConfig();
|
|
const cliproxyVariants = Object.keys(config.cliproxy || {});
|
|
|
|
const cliproxyConfig = config.cliproxy;
|
|
if (cliproxyVariants.length > 0 && cliproxyConfig) {
|
|
lines.push('CLIProxy variants (user-defined):');
|
|
cliproxyVariants.forEach((name) => {
|
|
const variant = cliproxyConfig[name];
|
|
lines.push(` - ${name} (${variant.provider})`);
|
|
});
|
|
}
|
|
|
|
// Settings-based profiles
|
|
const settingsProfiles = Object.keys(config.profiles || {});
|
|
|
|
if (settingsProfiles.length > 0) {
|
|
lines.push('Settings-based profiles (GLM, Kimi, etc.):');
|
|
settingsProfiles.forEach((name) => {
|
|
lines.push(` - ${name}`);
|
|
});
|
|
}
|
|
|
|
// Account-based profiles
|
|
const profiles = this.readProfiles();
|
|
const accountProfiles = Object.keys(profiles.profiles || {});
|
|
|
|
if (accountProfiles.length > 0) {
|
|
lines.push('Account-based profiles:');
|
|
accountProfiles.forEach((name) => {
|
|
const isDefault = name === profiles.default;
|
|
lines.push(` - ${name}${isDefault ? ' [DEFAULT]' : ''}`);
|
|
});
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Check if profile exists (any type)
|
|
*/
|
|
hasProfile(profileName: string): boolean {
|
|
try {
|
|
this.detectProfileType(profileName);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all available profile names
|
|
*/
|
|
getAllProfiles(): AllProfiles & { cliproxy: string[]; cliproxyVariants: string[] } {
|
|
// Check unified config first
|
|
const unifiedConfig = this.readUnifiedConfig();
|
|
if (unifiedConfig) {
|
|
return {
|
|
settings: Object.keys(unifiedConfig.profiles || {}),
|
|
accounts: Object.keys(unifiedConfig.accounts || {}),
|
|
cliproxy: [...CLIPROXY_PROFILES],
|
|
cliproxyVariants: Object.keys(unifiedConfig.cliproxy?.variants || {}),
|
|
default: unifiedConfig.default,
|
|
};
|
|
}
|
|
|
|
// Fall back to legacy config
|
|
const config = this.readConfig();
|
|
const profiles = this.readProfiles();
|
|
|
|
return {
|
|
settings: Object.keys(config.profiles || {}),
|
|
accounts: Object.keys(profiles.profiles || {}),
|
|
cliproxy: [...CLIPROXY_PROFILES],
|
|
cliproxyVariants: Object.keys(config.cliproxy || {}),
|
|
default: profiles.default,
|
|
};
|
|
}
|
|
}
|
|
|
|
export default ProfileDetector;
|