mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-19 16:18:42 +00:00
1514 lines
55 KiB
TypeScript
1514 lines
55 KiB
TypeScript
import './utils/fetch-proxy-setup';
|
|
|
|
import * as fs from 'fs';
|
|
import { detectClaudeCli } from './utils/claude-detector';
|
|
import {
|
|
getSettingsPath,
|
|
loadSettings,
|
|
setGlobalConfigDir,
|
|
detectCloudSyncPath,
|
|
} from './utils/config-manager';
|
|
import { expandPath } from './utils/helpers';
|
|
import {
|
|
validateGlmKey,
|
|
validateMiniMaxKey,
|
|
validateAnthropicKey,
|
|
} from './utils/api-key-validator';
|
|
import { ErrorManager } from './utils/error-manager';
|
|
import {
|
|
execClaudeWithCLIProxy,
|
|
CLIProxyProvider,
|
|
ensureCliproxyService,
|
|
isAuthenticated,
|
|
} from './cliproxy';
|
|
import { getEffectiveEnvVars, getCompositeEnvVars } from './cliproxy/config/env-builder';
|
|
import { CLIPROXY_DEFAULT_PORT } from './cliproxy/config/port-manager';
|
|
import {
|
|
ensureWebSearchMcpOrThrow,
|
|
displayWebSearchStatus,
|
|
getWebSearchHookEnv,
|
|
syncWebSearchMcpToConfigDir,
|
|
appendThirdPartyWebSearchToolArgs,
|
|
createWebSearchTraceContext,
|
|
} from './utils/websearch-manager';
|
|
import {
|
|
ensureImageAnalysisMcpOrThrow,
|
|
syncImageAnalysisMcpToConfigDir,
|
|
appendThirdPartyImageAnalysisToolArgs,
|
|
} from './utils/image-analysis';
|
|
import {
|
|
appendBrowserToolArgs,
|
|
ensureBrowserMcpOrThrow,
|
|
resolveBrowserRuntimeEnv,
|
|
resolveConfiguredBrowserProfileDir,
|
|
syncBrowserMcpToConfigDir,
|
|
} from './utils/browser';
|
|
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
|
|
import {
|
|
ensureProfileHooks as ensureImageAnalyzerHooks,
|
|
removeImageAnalysisProfileHook,
|
|
} from './utils/hooks/image-analyzer-profile-hook-injector';
|
|
import {
|
|
applyImageAnalysisRuntimeOverrides,
|
|
getImageAnalysisHookEnv,
|
|
installImageAnalyzerHook,
|
|
prepareImageAnalysisFallbackHook,
|
|
resolveImageAnalysisRuntimeConnection,
|
|
resolveImageAnalysisRuntimeStatus,
|
|
} from './utils/hooks';
|
|
import { fail, info, warn } from './utils/ui';
|
|
import { isCopilotSubcommandToken } from './copilot/constants';
|
|
import {
|
|
buildOfficialChannelsArgs,
|
|
getOfficialChannelsEnvironmentStatus,
|
|
officialChannelRequiresMacOS,
|
|
resolveOfficialChannelsLaunchPlan,
|
|
} from './channels/official-channels-runtime';
|
|
import { getOfficialChannelReadiness } from './channels/official-channels-store';
|
|
import { isCursorSubcommandToken } from './cursor/constants';
|
|
|
|
// Import centralized error handling
|
|
import { handleError, runCleanup } from './errors';
|
|
import { tryHandleRootCommand } from './commands/root-command-router';
|
|
|
|
// Import extracted utility functions
|
|
import { execClaude } from './utils/shell-executor';
|
|
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
|
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
|
import { createLogger } from './services/logging';
|
|
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
|
|
|
|
// Import target adapter system
|
|
import {
|
|
registerTarget,
|
|
getTarget,
|
|
ClaudeAdapter,
|
|
DroidAdapter,
|
|
CodexAdapter,
|
|
evaluateTargetRuntimeCompatibility,
|
|
pruneOrphanedModels,
|
|
resolveDroidProvider,
|
|
type TargetCredentials,
|
|
} from './targets';
|
|
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
|
|
import {
|
|
DroidReasoningFlagError,
|
|
resolveDroidReasoningRuntime,
|
|
} from './targets/droid-reasoning-runtime';
|
|
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
|
|
import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge';
|
|
|
|
// Version and Update check utilities
|
|
import { getVersion } from './utils/version';
|
|
import {
|
|
checkForUpdates,
|
|
showUpdateNotification,
|
|
checkCachedUpdate,
|
|
isCacheStale,
|
|
} from './utils/update-checker';
|
|
// Note: npm is now the only supported installation method
|
|
|
|
// ========== Profile Detection ==========
|
|
|
|
interface DetectedProfile {
|
|
profile: string;
|
|
remainingArgs: string[];
|
|
}
|
|
|
|
interface RuntimeReasoningResolution {
|
|
argsWithoutReasoningFlags: string[];
|
|
reasoningOverride: string | number | undefined;
|
|
reasoningSource: 'flag' | 'env' | undefined;
|
|
sourceDisplay: string | undefined;
|
|
}
|
|
|
|
const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
|
|
|
|
function resolveCodexRuntimeConfigOverrides(
|
|
target: ReturnType<typeof resolveTargetType>
|
|
): string[] {
|
|
if (target !== 'codex') {
|
|
return [];
|
|
}
|
|
return buildCodexBrowserMcpOverrides();
|
|
}
|
|
|
|
/**
|
|
* Smart profile detection
|
|
*/
|
|
function detectProfile(args: string[]): DetectedProfile {
|
|
if (args.length === 0 || args[0].startsWith('-')) {
|
|
// No args or first arg is a flag → use default profile
|
|
return { profile: 'default', remainingArgs: args };
|
|
} else {
|
|
// First arg doesn't start with '-' → treat as profile name
|
|
return { profile: args[0], remainingArgs: args.slice(1) };
|
|
}
|
|
}
|
|
|
|
function resolveRuntimeReasoningFlags(
|
|
args: string[],
|
|
envThinkingValue: string | undefined
|
|
): RuntimeReasoningResolution {
|
|
const runtime = resolveDroidReasoningRuntime(args, envThinkingValue);
|
|
|
|
if (runtime.duplicateDisplays.length > 0) {
|
|
console.error(
|
|
warn(
|
|
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
|
|
)
|
|
);
|
|
}
|
|
|
|
return {
|
|
argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags,
|
|
reasoningOverride: runtime.reasoningOverride,
|
|
reasoningSource: runtime.sourceFlag
|
|
? 'flag'
|
|
: runtime.reasoningOverride !== undefined
|
|
? 'env'
|
|
: undefined,
|
|
sourceDisplay: runtime.sourceDisplay,
|
|
};
|
|
}
|
|
|
|
function normalizeCodexRuntimeReasoningOverride(
|
|
value: string | number | undefined
|
|
): string | undefined {
|
|
return typeof value === 'string' && CODEX_RUNTIME_REASONING_LEVELS.has(value) ? value : undefined;
|
|
}
|
|
|
|
function exitWithRuntimeReasoningFlagError(
|
|
message: string,
|
|
options: {
|
|
codexAliasLevels: string;
|
|
includeDroidExecExample?: boolean;
|
|
}
|
|
): never {
|
|
console.error(fail(message));
|
|
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
|
|
console.error(` Codex alias: --effort ${options.codexAliasLevels}`);
|
|
if (options.includeDroidExecExample) {
|
|
console.error(' Droid exec: --reasoning-effort high');
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
// ========== Main Execution ==========
|
|
|
|
interface ProfileError extends Error {
|
|
profileName?: string;
|
|
availableProfiles?: string;
|
|
suggestions?: string[];
|
|
}
|
|
|
|
/**
|
|
* Perform background update check (refreshes cache, no notification)
|
|
*/
|
|
async function refreshUpdateCache(): Promise<void> {
|
|
try {
|
|
const currentVersion = getVersion();
|
|
// npm is now the only supported installation method
|
|
await checkForUpdates(currentVersion, true, 'npm');
|
|
} catch (_e) {
|
|
// Silently fail - update check shouldn't crash main CLI
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show update notification if cached result indicates update available
|
|
* Returns true if notification was shown
|
|
*/
|
|
async function showCachedUpdateNotification(): Promise<boolean> {
|
|
try {
|
|
const currentVersion = getVersion();
|
|
const updateInfo = checkCachedUpdate(currentVersion);
|
|
|
|
if (updateInfo) {
|
|
await showUpdateNotification(updateInfo);
|
|
return true;
|
|
}
|
|
} catch (_e) {
|
|
// Silently fail
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function resolveNativeClaudeLaunchArgs(
|
|
args: string[],
|
|
profileType: 'default' | 'account',
|
|
targetConfigDir?: string
|
|
): string[] {
|
|
const config = getOfficialChannelsConfig();
|
|
const environment = getOfficialChannelsEnvironmentStatus(
|
|
targetConfigDir ? { CLAUDE_CONFIG_DIR: targetConfigDir } : undefined
|
|
);
|
|
const channelReadiness = {
|
|
telegram: getOfficialChannelReadiness('telegram'),
|
|
discord: getOfficialChannelReadiness('discord'),
|
|
imessage: !officialChannelRequiresMacOS('imessage') || process.platform === 'darwin',
|
|
};
|
|
const plan = resolveOfficialChannelsLaunchPlan({
|
|
args,
|
|
config,
|
|
target: 'claude',
|
|
profileType,
|
|
environment,
|
|
channelReadiness,
|
|
});
|
|
|
|
for (const message of plan.skippedMessages) {
|
|
console.error(warn(message));
|
|
}
|
|
|
|
if (
|
|
config.selected.length > 0 &&
|
|
environment.auth.state === 'eligible' &&
|
|
environment.auth.orgRequirementMessage
|
|
) {
|
|
console.error(warn(environment.auth.orgRequirementMessage));
|
|
}
|
|
|
|
if (!plan.applied) {
|
|
return args;
|
|
}
|
|
|
|
return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass);
|
|
}
|
|
|
|
function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean {
|
|
return getNativeCodexPassthroughArgs(args) !== null;
|
|
}
|
|
|
|
function getNativeCodexPassthroughArgs(args: string[]): string[] | null {
|
|
const targetArgs = stripTargetFlag(args);
|
|
if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const firstArg = targetArgs[0] || '';
|
|
if (CODEX_NATIVE_PASSTHROUGH_FLAGS.has(firstArg)) {
|
|
return targetArgs;
|
|
}
|
|
|
|
const secondArg = targetArgs[1] || '';
|
|
if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_FLAGS.has(secondArg)) {
|
|
return targetArgs.slice(1);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function execNativeCodexFlagCommand(args: string[]): void {
|
|
const adapter = getTarget('codex');
|
|
if (!adapter) {
|
|
console.error(fail('Target adapter not found for "codex"'));
|
|
process.exit(1);
|
|
}
|
|
|
|
const binaryInfo = adapter.detectBinary();
|
|
if (!binaryInfo) {
|
|
console.error(fail('Codex CLI not found.'));
|
|
console.error(info('Install a recent @openai/codex build, then retry.'));
|
|
process.exit(1);
|
|
}
|
|
|
|
const targetArgs = getNativeCodexPassthroughArgs(args);
|
|
if (!targetArgs) {
|
|
console.error(fail('Native Codex passthrough args could not be resolved.'));
|
|
process.exit(1);
|
|
}
|
|
const creds: TargetCredentials = {
|
|
profile: 'default',
|
|
baseUrl: '',
|
|
apiKey: '',
|
|
};
|
|
|
|
const builtArgs = adapter.buildArgs('default', targetArgs, {
|
|
creds,
|
|
profileType: 'default',
|
|
binaryInfo,
|
|
});
|
|
const targetEnv = adapter.buildEnv(creds, 'default');
|
|
adapter.exec(builtArgs, targetEnv, { binaryInfo });
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
// Register target adapters
|
|
registerTarget(new ClaudeAdapter());
|
|
registerTarget(new DroidAdapter());
|
|
registerTarget(new CodexAdapter());
|
|
const cliLogger = createLogger('cli');
|
|
|
|
const args = process.argv.slice(2);
|
|
const isCompletionCommand = args[0] === '__complete';
|
|
|
|
// Initialize UI colors early to ensure consistent colored output
|
|
// Must happen before any status messages (ok, info, fail, etc.)
|
|
if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) {
|
|
const { initUI } = await import('./utils/ui');
|
|
await initUI();
|
|
}
|
|
|
|
// Parse --config-dir flag (must happen before any config loading)
|
|
const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir='));
|
|
if (configDirIdx !== -1) {
|
|
const arg = args[configDirIdx];
|
|
let configDirValue: string | undefined;
|
|
let spliceCount = 1;
|
|
|
|
if (arg.startsWith('--config-dir=')) {
|
|
configDirValue = arg.split('=').slice(1).join('=');
|
|
} else {
|
|
configDirValue = args[configDirIdx + 1];
|
|
spliceCount = 2;
|
|
}
|
|
|
|
if (!configDirValue || configDirValue.startsWith('-')) {
|
|
console.error(fail('--config-dir requires a path argument'));
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
const stat = fs.statSync(configDirValue);
|
|
if (!stat.isDirectory()) {
|
|
console.error(fail(`Not a directory: ${configDirValue}`));
|
|
process.exit(1);
|
|
}
|
|
} catch {
|
|
console.error(fail(`Config directory not found: ${configDirValue}`));
|
|
console.error(info('Create the directory first, then copy your config files into it.'));
|
|
process.exit(1);
|
|
}
|
|
|
|
setGlobalConfigDir(configDirValue);
|
|
|
|
// Security warning: cloud sync paths expose OAuth tokens
|
|
const cloudService = detectCloudSyncPath(configDirValue);
|
|
if (!isCompletionCommand && cloudService) {
|
|
console.error(warn(`CCS directory is under ${cloudService}.`));
|
|
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
|
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
|
}
|
|
|
|
// Remove consumed args so they don't leak to Claude CLI
|
|
args.splice(configDirIdx, spliceCount);
|
|
} else if (process.env.CCS_DIR) {
|
|
// Also warn for CCS_DIR env var pointing to cloud sync
|
|
const cloudService = detectCloudSyncPath(process.env.CCS_DIR);
|
|
if (!isCompletionCommand && cloudService) {
|
|
console.error(warn(`CCS directory is under ${cloudService}.`));
|
|
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
|
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
|
}
|
|
} else if (process.env.CCS_HOME) {
|
|
// Also warn for CCS_HOME env var pointing to cloud sync
|
|
const cloudService = detectCloudSyncPath(process.env.CCS_HOME);
|
|
if (!isCompletionCommand && cloudService) {
|
|
console.error(warn(`CCS directory is under ${cloudService}.`));
|
|
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
|
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
|
}
|
|
}
|
|
|
|
if (isCompletionCommand) {
|
|
await tryHandleRootCommand(args);
|
|
return;
|
|
}
|
|
|
|
cliLogger.info('command.start', 'CLI invocation started', {
|
|
command: args[0] || 'default',
|
|
argCount: args.length,
|
|
flags: args.filter((arg) => arg.startsWith('-')).slice(0, 20),
|
|
});
|
|
|
|
if (shouldPassthroughNativeCodexFlagCommand(args)) {
|
|
execNativeCodexFlagCommand(args);
|
|
return;
|
|
}
|
|
|
|
const firstArg = args[0];
|
|
|
|
// Trigger update check early for ALL commands except version/help/update
|
|
// Only if TTY and not CI to avoid noise in automated environments
|
|
const skipUpdateCheck = [
|
|
'version',
|
|
'--version',
|
|
'-v',
|
|
'help',
|
|
'--help',
|
|
'-h',
|
|
'update',
|
|
'--update',
|
|
];
|
|
if (process.stdout.isTTY && !process.env['CI'] && !skipUpdateCheck.includes(firstArg)) {
|
|
// 1. Show cached update notification (async for proper UI)
|
|
await showCachedUpdateNotification();
|
|
|
|
// 2. Refresh cache in background if stale (non-blocking)
|
|
if (isCacheStale()) {
|
|
refreshUpdateCache();
|
|
}
|
|
}
|
|
|
|
// Auto-migrate to unified config format (silent if already migrated)
|
|
// Skip if user is explicitly running migrate command
|
|
if (firstArg !== 'migrate') {
|
|
const { autoMigrate } = await import('./config/migration-manager');
|
|
await autoMigrate();
|
|
}
|
|
|
|
// Auto-recovery for missing configuration (BEFORE any early-exit commands)
|
|
// This ensures ALL commands benefit from auto-recovery, not just profile-switching flow
|
|
// Recovery is safe to run early - it only creates missing files with safe defaults
|
|
// Wrapped in try-catch to prevent blocking --version/--help on permission errors
|
|
try {
|
|
const RecoveryManagerModule = await import('./management/recovery-manager');
|
|
const RecoveryManager = RecoveryManagerModule.default;
|
|
const recovery = new RecoveryManager();
|
|
const recovered = recovery.recoverAll();
|
|
|
|
if (recovered) {
|
|
recovery.showRecoveryHints();
|
|
}
|
|
} catch (err) {
|
|
cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', {
|
|
message: (err as Error).message,
|
|
});
|
|
// Recovery is best-effort - don't block basic CLI functionality
|
|
console.warn('[!] Recovery failed:', (err as Error).message);
|
|
}
|
|
|
|
if (await tryHandleRootCommand(args)) {
|
|
return;
|
|
}
|
|
|
|
// Special case: copilot command (GitHub Copilot integration)
|
|
// Route known subcommands to command handler, keep all other args as profile passthrough.
|
|
if (firstArg === 'copilot' && args.length > 1) {
|
|
const copilotToken = args[1];
|
|
const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken);
|
|
|
|
if (shouldRouteToCopilotCommand) {
|
|
const { handleCopilotCommand } = await import('./commands/copilot-command');
|
|
const exitCode = await handleCopilotCommand(args.slice(1));
|
|
process.exit(exitCode);
|
|
}
|
|
}
|
|
|
|
// Special case: cursor command (Cursor local proxy integration)
|
|
// Route known admin subcommands to the command handler, keep all other args as profile passthrough.
|
|
if (firstArg === 'cursor' && args.length > 1) {
|
|
const { handleCursorCommand } = await import('./commands/cursor-command');
|
|
const cursorToken = args[1];
|
|
|
|
if (isCursorSubcommandToken(cursorToken)) {
|
|
const exitCode = await handleCursorCommand(args.slice(1));
|
|
process.exit(exitCode);
|
|
}
|
|
}
|
|
|
|
// First-time install: offer setup wizard for interactive users
|
|
// Check independently of recovery status (user may have empty config.yaml)
|
|
// Skip if headless, CI, or non-TTY environment
|
|
const { isFirstTimeInstall } = await import('./commands/setup-command');
|
|
if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) {
|
|
console.log('');
|
|
console.log(info('First-time install detected. Run `ccs setup` for guided configuration.'));
|
|
console.log(' Or use `ccs config` for the web dashboard.');
|
|
console.log('');
|
|
}
|
|
|
|
// Use ProfileDetector to determine profile type
|
|
const ProfileDetectorModule = await import('./auth/profile-detector');
|
|
const ProfileDetector = ProfileDetectorModule.default;
|
|
const InstanceManagerModule = await import('./management/instance-manager');
|
|
const InstanceManager = InstanceManagerModule.default;
|
|
const ProfileRegistryModule = await import('./auth/profile-registry');
|
|
const ProfileRegistry = ProfileRegistryModule.default;
|
|
const AccountContextModule = await import('./auth/account-context');
|
|
const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule;
|
|
const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance');
|
|
const { resolveProfileContinuityInheritance } = ProfileContinuityModule;
|
|
|
|
const detector = new ProfileDetector();
|
|
|
|
try {
|
|
// Detect profile (strip --target flags before profile detection)
|
|
const cleanArgs = stripTargetFlag(args);
|
|
const { profile, remainingArgs } = detectProfile(cleanArgs);
|
|
const profileInfo = detector.detectProfileType(profile);
|
|
let resolvedTarget: ReturnType<typeof resolveTargetType>;
|
|
try {
|
|
resolvedTarget = resolveTargetType(
|
|
args,
|
|
profileInfo.target ? { target: profileInfo.target } : undefined
|
|
);
|
|
} catch (error) {
|
|
console.error(fail((error as Error).message));
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
|
|
// Detect Claude CLI (needed for claude target and all CLIProxy-derived flows)
|
|
const claudeCliRaw = detectClaudeCli();
|
|
if (resolvedTarget === 'claude' && !claudeCliRaw) {
|
|
await ErrorManager.showClaudeNotFound();
|
|
process.exit(1);
|
|
}
|
|
const claudeCli = claudeCliRaw || '';
|
|
|
|
// Resolve non-claude target adapter once.
|
|
const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null;
|
|
let resolvedSettingsPath: string | undefined;
|
|
let resolvedSettings: ReturnType<typeof loadSettings> | undefined;
|
|
let resolvedCliproxyBridge: ReturnType<typeof resolveCliproxyBridgeMetadata> | undefined;
|
|
|
|
// Preflight unsupported profile/target combinations BEFORE binary detection,
|
|
// so users get the most actionable error even when the target CLI is not installed.
|
|
if (resolvedTarget !== 'claude') {
|
|
if (!targetAdapter) {
|
|
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
|
process.exit(1);
|
|
}
|
|
|
|
if (profileInfo.type === 'settings') {
|
|
resolvedSettingsPath = profileInfo.settingsPath
|
|
? expandPath(profileInfo.settingsPath)
|
|
: getSettingsPath(profileInfo.name);
|
|
resolvedSettings = loadSettings(resolvedSettingsPath);
|
|
resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings);
|
|
const compatibility = evaluateTargetRuntimeCompatibility({
|
|
target: resolvedTarget,
|
|
profileType: profileInfo.type,
|
|
cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null,
|
|
});
|
|
if (!compatibility.supported) {
|
|
console.error(
|
|
fail(
|
|
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
|
|
)
|
|
);
|
|
if (compatibility.suggestion) {
|
|
console.error(info(compatibility.suggestion));
|
|
}
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
const compatibility = evaluateTargetRuntimeCompatibility({
|
|
target: resolvedTarget,
|
|
profileType: profileInfo.type,
|
|
cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined,
|
|
isComposite:
|
|
profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined,
|
|
});
|
|
if (!compatibility.supported) {
|
|
console.error(
|
|
fail(
|
|
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
|
|
)
|
|
);
|
|
if (compatibility.suggestion) {
|
|
console.error(info(compatibility.suggestion));
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (profileInfo.type === 'default') {
|
|
if (!targetAdapter.supportsProfileType('default')) {
|
|
console.error(fail(`${targetAdapter.displayName} does not support default profile mode`));
|
|
process.exit(1);
|
|
}
|
|
|
|
// For default mode, Droid requires explicit credentials from environment.
|
|
if (resolvedTarget === 'droid') {
|
|
const baseUrl = process.env['ANTHROPIC_BASE_URL'] || '';
|
|
const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || '';
|
|
if (!baseUrl.trim() || !apiKey.trim()) {
|
|
console.error(
|
|
fail(
|
|
`${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
|
|
)
|
|
);
|
|
console.error(info('Use a settings-based profile instead: ccs glm --target droid'));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// For non-claude targets, verify target binary exists once and pass it through.
|
|
const targetBinaryInfo = targetAdapter?.detectBinary() ?? null;
|
|
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(resolvedTarget);
|
|
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
|
|
const displayName = targetAdapter?.displayName || resolvedTarget;
|
|
console.error(fail(`${displayName} CLI not found.`));
|
|
if (resolvedTarget === 'droid') {
|
|
console.error(info('Install: npm i -g @factory/cli'));
|
|
} else if (resolvedTarget === 'codex') {
|
|
console.error(info('Install a recent @openai/codex build, then retry.'));
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
// Best-effort: prune stale Droid model entries at runtime so settings.json stays clean.
|
|
if (resolvedTarget === 'droid') {
|
|
try {
|
|
const allProfiles = detector.getAllProfiles();
|
|
const activeProfiles = allProfiles.settings.filter((name) =>
|
|
/^[a-zA-Z0-9._-]+$/.test(name)
|
|
);
|
|
await pruneOrphanedModels(activeProfiles);
|
|
} catch (error) {
|
|
console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`));
|
|
}
|
|
}
|
|
|
|
let targetRemainingArgs = remainingArgs;
|
|
let runtimeReasoningOverride: string | number | undefined;
|
|
if (resolvedTarget === 'droid') {
|
|
try {
|
|
const droidRoute = routeDroidCommandArgs(remainingArgs);
|
|
targetRemainingArgs = droidRoute.argsForDroid;
|
|
|
|
if (droidRoute.mode === 'interactive') {
|
|
const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING);
|
|
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
|
|
runtimeReasoningOverride = runtime.reasoningOverride;
|
|
} else {
|
|
if (droidRoute.duplicateReasoningDisplays.length > 0) {
|
|
console.error(
|
|
warn(
|
|
`[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || '<first-flag>'}`
|
|
)
|
|
);
|
|
}
|
|
if (droidRoute.autoPrependedExec && process.stdout.isTTY) {
|
|
console.error(
|
|
info('Detected Droid exec-only flags. Routing as: droid exec <flags> [prompt]')
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) {
|
|
exitWithRuntimeReasoningFlagError(error.message, {
|
|
codexAliasLevels: 'minimal|low|medium|high|xhigh',
|
|
includeDroidExecExample: true,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
} else if (resolvedTarget === 'codex') {
|
|
try {
|
|
const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING);
|
|
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
|
|
const normalizedReasoning = normalizeCodexRuntimeReasoningOverride(
|
|
runtime.reasoningOverride
|
|
);
|
|
if (runtime.reasoningOverride !== undefined && !normalizedReasoning) {
|
|
if (runtime.reasoningSource === 'flag') {
|
|
throw new DroidReasoningFlagError(
|
|
'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.',
|
|
'--effort'
|
|
);
|
|
}
|
|
runtimeReasoningOverride = undefined;
|
|
} else {
|
|
runtimeReasoningOverride = normalizedReasoning;
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof DroidReasoningFlagError) {
|
|
exitWithRuntimeReasoningFlagError(error.message, {
|
|
codexAliasLevels: 'minimal|low|medium|high|xhigh',
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Special case: headless delegation (-p/--prompt)
|
|
// Keep existing behavior for Claude targets only; non-claude targets must continue
|
|
// through normal adapter dispatch logic.
|
|
if (args.includes('-p') || args.includes('--prompt')) {
|
|
const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type !== 'cliproxy';
|
|
if (shouldUseDelegation) {
|
|
const { DelegationHandler } = await import('./delegation/delegation-handler');
|
|
const handler = new DelegationHandler();
|
|
await handler.route(cleanArgs);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (profileInfo.type === 'cliproxy') {
|
|
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
|
|
const imageAnalysisMcpReady =
|
|
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
|
|
if (resolvedTarget === 'claude') {
|
|
ensureWebSearchMcpOrThrow();
|
|
}
|
|
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
|
|
const expandedCliproxySettingsPath = profileInfo.settingsPath
|
|
? expandPath(profileInfo.settingsPath)
|
|
: undefined;
|
|
if (resolvedTarget === 'claude') {
|
|
if (imageAnalysisMcpReady) {
|
|
removeImageAnalysisProfileHook(profileInfo.name, expandedCliproxySettingsPath);
|
|
} else {
|
|
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
|
ensureImageAnalyzerHooks({
|
|
profileName: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
cliproxyProvider: provider,
|
|
isComposite: profileInfo.isComposite,
|
|
settingsPath: expandedCliproxySettingsPath,
|
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
|
});
|
|
}
|
|
}
|
|
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
|
|
const variantPort = profileInfo.port; // variant-specific port for isolation
|
|
const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT;
|
|
|
|
if (resolvedTarget !== 'claude') {
|
|
const adapter = targetAdapter;
|
|
if (!adapter) {
|
|
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (!adapter.supportsProfileType('cliproxy')) {
|
|
console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Keep CLIProxy management/auth flags on Claude flow only.
|
|
const unsupportedCliproxyFlags = [
|
|
'--auth',
|
|
'--logout',
|
|
'--accounts',
|
|
'--add',
|
|
'--use',
|
|
'--config',
|
|
'--headless',
|
|
'--paste-callback',
|
|
'--port-forward',
|
|
'--nickname',
|
|
'--kiro-auth-method',
|
|
'--kiro-idc-start-url',
|
|
'--kiro-idc-region',
|
|
'--kiro-idc-flow',
|
|
'--backend',
|
|
'--proxy-host',
|
|
'--proxy-port',
|
|
'--proxy-protocol',
|
|
'--proxy-auth-token',
|
|
'--proxy-timeout',
|
|
'--local-proxy',
|
|
'--remote-only',
|
|
'--no-fallback',
|
|
'--allow-self-signed',
|
|
'--1m',
|
|
'--no-1m',
|
|
];
|
|
const providedUnsupportedFlag = unsupportedCliproxyFlags.find(
|
|
(flag) =>
|
|
targetRemainingArgs.includes(flag) ||
|
|
targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`))
|
|
);
|
|
if (providedUnsupportedFlag) {
|
|
console.error(
|
|
fail(
|
|
`${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target`
|
|
)
|
|
);
|
|
console.error(
|
|
info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`)
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// For Droid execution path, require existing OAuth auth and running local proxy.
|
|
if (profileInfo.isComposite && profileInfo.compositeTiers) {
|
|
const compositeProviders = [
|
|
...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)),
|
|
] as CLIProxyProvider[];
|
|
const missingProvider = compositeProviders.find((p) => !isAuthenticated(p));
|
|
if (missingProvider) {
|
|
console.error(
|
|
fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`)
|
|
);
|
|
console.error(info(`Authenticate first: ccs ${missingProvider} --auth`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
} else if (!isAuthenticated(provider)) {
|
|
console.error(fail(`No OAuth authentication found for provider: ${provider}`));
|
|
console.error(info(`Authenticate first: ccs ${provider} --auth`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const ensureServiceResult = await ensureCliproxyService(
|
|
cliproxyPort,
|
|
targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v')
|
|
);
|
|
if (!ensureServiceResult.started) {
|
|
console.error(
|
|
fail(ensureServiceResult.error || 'Failed to start local CLIProxy service')
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const envVars =
|
|
profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier
|
|
? getCompositeEnvVars(
|
|
profileInfo.compositeTiers,
|
|
profileInfo.compositeDefaultTier,
|
|
cliproxyPort,
|
|
customSettingsPath
|
|
)
|
|
: getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath);
|
|
|
|
const creds: TargetCredentials = {
|
|
profile: profileInfo.name,
|
|
baseUrl: envVars['ANTHROPIC_BASE_URL'] || '',
|
|
apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '',
|
|
model: envVars['ANTHROPIC_MODEL'] || undefined,
|
|
provider: resolveDroidProvider({
|
|
provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'],
|
|
baseUrl: envVars['ANTHROPIC_BASE_URL'],
|
|
model: envVars['ANTHROPIC_MODEL'],
|
|
}),
|
|
reasoningOverride: runtimeReasoningOverride,
|
|
runtimeConfigOverrides: codexRuntimeConfigOverrides,
|
|
envVars,
|
|
};
|
|
|
|
if (!creds.baseUrl || !creds.apiKey) {
|
|
console.error(
|
|
fail(
|
|
`Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)`
|
|
)
|
|
);
|
|
console.error(
|
|
info('Reconfigure with: ccs config > CLIProxy, or run ccs <provider> --config')
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
await adapter.prepareCredentials(creds);
|
|
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, {
|
|
creds,
|
|
profileType: profileInfo.type,
|
|
binaryInfo: targetBinaryInfo || undefined,
|
|
});
|
|
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
|
|
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
|
return;
|
|
}
|
|
|
|
await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, {
|
|
customSettingsPath,
|
|
port: cliproxyPort,
|
|
isComposite: profileInfo.isComposite,
|
|
compositeTiers: profileInfo.compositeTiers,
|
|
compositeDefaultTier: profileInfo.compositeDefaultTier,
|
|
profileName: profileInfo.name,
|
|
});
|
|
} else if (profileInfo.type === 'copilot') {
|
|
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
|
|
ensureWebSearchMcpOrThrow();
|
|
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
|
if (resolvedTarget === 'claude') {
|
|
if (imageAnalysisMcpReady) {
|
|
removeImageAnalysisProfileHook(profileInfo.name);
|
|
} else {
|
|
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
|
ensureImageAnalyzerHooks({
|
|
profileName: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
|
});
|
|
}
|
|
}
|
|
|
|
const { executeCopilotProfile } = await import('./copilot');
|
|
const copilotConfig = profileInfo.copilotConfig;
|
|
if (!copilotConfig) {
|
|
console.error(fail('Copilot configuration not found'));
|
|
process.exit(1);
|
|
}
|
|
const continuityInheritance = await resolveProfileContinuityInheritance({
|
|
profileName: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
target: resolvedTarget,
|
|
});
|
|
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
|
console.error(
|
|
info(
|
|
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
|
|
)
|
|
);
|
|
}
|
|
const exitCode = await executeCopilotProfile(
|
|
copilotConfig,
|
|
remainingArgs,
|
|
continuityInheritance.claudeConfigDir,
|
|
claudeCli
|
|
);
|
|
process.exit(exitCode);
|
|
} else if (profileInfo.type === 'cursor') {
|
|
// CURSOR FLOW: local Cursor daemon profile
|
|
ensureWebSearchMcpOrThrow();
|
|
installImageAnalyzerHook();
|
|
ensureImageAnalyzerHooks({
|
|
profileName: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
});
|
|
|
|
const { executeCursorProfile } = await import('./cursor');
|
|
const cursorConfig = profileInfo.cursorConfig;
|
|
if (!cursorConfig) {
|
|
console.error(fail('Cursor configuration not found'));
|
|
process.exit(1);
|
|
}
|
|
const continuityInheritance = await resolveProfileContinuityInheritance({
|
|
profileName: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
target: resolvedTarget,
|
|
});
|
|
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
|
console.error(
|
|
info(
|
|
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
|
|
)
|
|
);
|
|
}
|
|
const exitCode = await executeCursorProfile(
|
|
cursorConfig,
|
|
remainingArgs,
|
|
continuityInheritance.claudeConfigDir,
|
|
claudeCli
|
|
);
|
|
process.exit(exitCode);
|
|
} else if (profileInfo.type === 'settings') {
|
|
// Settings-based profiles (glm, glmt) are third-party providers
|
|
const imageAnalysisMcpReady =
|
|
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
|
|
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
|
const browserProfileDir =
|
|
resolvedTarget === 'claude'
|
|
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
|
|
: undefined;
|
|
if (resolvedTarget === 'claude') {
|
|
ensureWebSearchMcpOrThrow();
|
|
if (browserProfileDir) {
|
|
ensureBrowserMcpOrThrow();
|
|
}
|
|
}
|
|
|
|
// Display WebSearch status (single line, equilibrium UX)
|
|
displayWebSearchStatus();
|
|
|
|
const continuityInheritance =
|
|
resolvedTarget === 'claude'
|
|
? await resolveProfileContinuityInheritance({
|
|
profileName: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
target: resolvedTarget,
|
|
})
|
|
: {};
|
|
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
|
console.error(
|
|
info(
|
|
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
|
|
)
|
|
);
|
|
}
|
|
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
|
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
|
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
|
if (
|
|
browserProfileDir &&
|
|
inheritedClaudeConfigDir &&
|
|
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
|
) {
|
|
throw new Error(
|
|
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
|
|
);
|
|
}
|
|
const expandedSettingsPath =
|
|
resolvedSettingsPath ??
|
|
(profileInfo.settingsPath
|
|
? expandPath(profileInfo.settingsPath)
|
|
: getSettingsPath(profileInfo.name));
|
|
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
|
|
const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
|
|
|
|
let imageAnalysisFallbackHookReady: boolean | undefined;
|
|
if (resolvedTarget === 'claude') {
|
|
if (imageAnalysisMcpReady) {
|
|
removeImageAnalysisProfileHook(profileInfo.name, expandedSettingsPath);
|
|
} else {
|
|
imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
|
ensureImageAnalyzerHooks({
|
|
profileName: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
settingsPath: expandedSettingsPath,
|
|
settings,
|
|
cliproxyBridge,
|
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
|
});
|
|
}
|
|
}
|
|
if (resolvedTarget !== 'claude') {
|
|
const compatibility = evaluateTargetRuntimeCompatibility({
|
|
target: resolvedTarget,
|
|
profileType: profileInfo.type,
|
|
cliproxyBridgeProvider: cliproxyBridge?.provider ?? null,
|
|
});
|
|
if (!compatibility.supported) {
|
|
console.error(
|
|
fail(
|
|
compatibility.reason ||
|
|
`${targetAdapter?.displayName || resolvedTarget} does not support this profile.`
|
|
)
|
|
);
|
|
if (compatibility.suggestion) {
|
|
console.error(info(compatibility.suggestion));
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
const rawSettingsEnv = profileInfo.env ?? settings.env ?? {};
|
|
const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name);
|
|
const glmtNormalization = isDeprecatedGlmtProfile
|
|
? normalizeDeprecatedGlmtEnv(rawSettingsEnv)
|
|
: null;
|
|
const settingsEnv = glmtNormalization?.env ?? rawSettingsEnv;
|
|
|
|
if (glmtNormalization) {
|
|
for (const message of glmtNormalization.warnings) {
|
|
console.error(warn(message));
|
|
}
|
|
}
|
|
|
|
// Pre-flight validation for Z.AI-compatible profiles.
|
|
if (profileInfo.name === 'glm' || isDeprecatedGlmtProfile) {
|
|
const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN'];
|
|
|
|
if (apiKey) {
|
|
const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
|
|
|
|
if (!validation.valid) {
|
|
console.error('');
|
|
console.error(fail(validation.error || 'API key validation failed'));
|
|
if (validation.suggestion) {
|
|
console.error('');
|
|
console.error(validation.suggestion);
|
|
}
|
|
console.error('');
|
|
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"'));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (profileInfo.name === 'mm') {
|
|
const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN'];
|
|
|
|
if (apiKey) {
|
|
const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
|
|
|
|
if (!validation.valid) {
|
|
console.error('');
|
|
console.error(fail(validation.error || 'API key validation failed'));
|
|
if (validation.suggestion) {
|
|
console.error('');
|
|
console.error(validation.suggestion);
|
|
}
|
|
console.error('');
|
|
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"'));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pre-flight validation for Anthropic direct profiles (ANTHROPIC_API_KEY + no BASE_URL)
|
|
{
|
|
const anthropicApiKey = settingsEnv['ANTHROPIC_API_KEY'];
|
|
const hasBaseUrl = !!settingsEnv['ANTHROPIC_BASE_URL'];
|
|
if (anthropicApiKey && !hasBaseUrl) {
|
|
const validation = await validateAnthropicKey(anthropicApiKey);
|
|
if (!validation.valid) {
|
|
console.error('');
|
|
console.error(fail(validation.error || 'API key validation failed'));
|
|
if (validation.suggestion) {
|
|
console.error('');
|
|
console.error(validation.suggestion);
|
|
}
|
|
console.error('');
|
|
console.error(
|
|
info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`)
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
const webSearchEnv = getWebSearchHookEnv();
|
|
const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({
|
|
profileName: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
settings,
|
|
cliproxyBridge,
|
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
|
});
|
|
const runtimeConnection = resolveImageAnalysisRuntimeConnection();
|
|
let imageAnalysisEnv = getImageAnalysisHookEnv({
|
|
profileName: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
settings,
|
|
cliproxyBridge,
|
|
});
|
|
imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, {
|
|
backendId: imageAnalysisStatus.backendId,
|
|
model: imageAnalysisStatus.model,
|
|
runtimePath: imageAnalysisStatus.runtimePath,
|
|
baseUrl: runtimeConnection.baseUrl,
|
|
apiKey: runtimeConnection.apiKey,
|
|
allowSelfSigned: runtimeConnection.allowSelfSigned,
|
|
});
|
|
imageAnalysisEnv = {
|
|
...imageAnalysisEnv,
|
|
CCS_IMAGE_ANALYSIS_SKIP_HOOK:
|
|
resolvedTarget === 'claude' && imageAnalysisMcpReady ? '1' : '0',
|
|
};
|
|
|
|
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
|
|
if (
|
|
resolvedTarget === 'claude' &&
|
|
imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' &&
|
|
imageAnalysisProvider
|
|
) {
|
|
const verboseProxyLaunch =
|
|
remainingArgs.includes('--verbose') ||
|
|
remainingArgs.includes('-v') ||
|
|
targetRemainingArgs.includes('--verbose') ||
|
|
targetRemainingArgs.includes('-v');
|
|
|
|
if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') {
|
|
console.error(
|
|
info(
|
|
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.`
|
|
)
|
|
);
|
|
imageAnalysisEnv = {
|
|
...imageAnalysisEnv,
|
|
CCS_CURRENT_PROVIDER: '',
|
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
|
};
|
|
} else if (imageAnalysisStatus.proxyReadiness === 'stopped') {
|
|
const ensureServiceResult = await ensureCliproxyService(
|
|
CLIPROXY_DEFAULT_PORT,
|
|
verboseProxyLaunch
|
|
);
|
|
if (!ensureServiceResult.started) {
|
|
console.error(
|
|
warn(
|
|
`Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.`
|
|
)
|
|
);
|
|
imageAnalysisEnv = {
|
|
...imageAnalysisEnv,
|
|
CCS_CURRENT_PROVIDER: '',
|
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
|
};
|
|
}
|
|
}
|
|
}
|
|
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
|
const globalEnvConfig = getGlobalEnvConfig();
|
|
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
|
|
|
// Log global env injection for visibility (debug mode only)
|
|
if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) {
|
|
const envNames = Object.keys(globalEnv).join(', ');
|
|
console.error(info(`Global env: ${envNames}`));
|
|
}
|
|
|
|
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
|
|
// values from prior sessions cannot leak into the active profile.
|
|
if (browserProfileDir) {
|
|
browserRuntimeEnv = {
|
|
...(await resolveBrowserRuntimeEnv({
|
|
profileDir: browserProfileDir,
|
|
})),
|
|
};
|
|
}
|
|
|
|
const envVars: NodeJS.ProcessEnv = {
|
|
...globalEnv,
|
|
...settingsEnv,
|
|
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
|
...webSearchEnv,
|
|
...imageAnalysisEnv,
|
|
...(browserRuntimeEnv || {}),
|
|
CCS_PROFILE_TYPE: 'settings',
|
|
};
|
|
|
|
// Dispatch through target adapter for non-claude targets
|
|
if (resolvedTarget !== 'claude') {
|
|
const adapter = targetAdapter;
|
|
if (!adapter) {
|
|
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
|
process.exit(1);
|
|
}
|
|
const directAnthropicBaseUrl =
|
|
settingsEnv['ANTHROPIC_BASE_URL'] ||
|
|
(settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : '');
|
|
const creds: TargetCredentials = {
|
|
profile: profileInfo.name,
|
|
baseUrl: directAnthropicBaseUrl,
|
|
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '',
|
|
model: settingsEnv['ANTHROPIC_MODEL'],
|
|
provider: resolveDroidProvider({
|
|
provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'],
|
|
baseUrl: directAnthropicBaseUrl,
|
|
model: settingsEnv['ANTHROPIC_MODEL'],
|
|
}),
|
|
reasoningOverride: runtimeReasoningOverride,
|
|
runtimeConfigOverrides: codexRuntimeConfigOverrides,
|
|
envVars,
|
|
};
|
|
await adapter.prepareCredentials(creds);
|
|
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, {
|
|
creds,
|
|
profileType: profileInfo.type,
|
|
binaryInfo: targetBinaryInfo || undefined,
|
|
});
|
|
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
|
|
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
|
return;
|
|
}
|
|
|
|
const imageAnalysisArgs = imageAnalysisMcpReady
|
|
? appendThirdPartyImageAnalysisToolArgs(remainingArgs)
|
|
: remainingArgs;
|
|
const browserArgs = browserRuntimeEnv
|
|
? appendBrowserToolArgs(imageAnalysisArgs)
|
|
: imageAnalysisArgs;
|
|
const launchArgs = [
|
|
'--settings',
|
|
expandedSettingsPath,
|
|
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
|
];
|
|
const traceEnv = createWebSearchTraceContext({
|
|
launcher: 'ccs.settings-profile',
|
|
args: launchArgs,
|
|
profile: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
settingsPath: expandedSettingsPath,
|
|
});
|
|
|
|
execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv });
|
|
} else if (profileInfo.type === 'account') {
|
|
// NEW FLOW: Account-based profile (work, personal)
|
|
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
|
|
const registry = new ProfileRegistry();
|
|
const instanceMgr = new InstanceManager();
|
|
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
|
|
? profileInfo.profile
|
|
: undefined;
|
|
const isBareProfile =
|
|
typeof profileInfo.profile === 'object' &&
|
|
profileInfo.profile !== null &&
|
|
(profileInfo.profile as { bare?: unknown }).bare === true;
|
|
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
|
|
|
|
// Ensure instance exists (lazy init if needed)
|
|
const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy, {
|
|
bare: isBareProfile,
|
|
});
|
|
|
|
// Update last_used timestamp (check unified config first, fallback to legacy)
|
|
if (registry.hasAccountUnified(profileInfo.name)) {
|
|
registry.touchAccountUnified(profileInfo.name);
|
|
} else {
|
|
registry.touchProfile(profileInfo.name);
|
|
}
|
|
|
|
// Execute Claude with instance isolation
|
|
// Skip WebSearch hook - account profiles use native server-side WebSearch
|
|
// Skip Image Analyzer hook - account profiles have native vision support
|
|
const envVars: NodeJS.ProcessEnv = {
|
|
CLAUDE_CONFIG_DIR: instancePath,
|
|
CCS_PROFILE_TYPE: 'account',
|
|
CCS_WEBSEARCH_SKIP: '1',
|
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
|
};
|
|
await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, remainingArgs);
|
|
const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath);
|
|
execClaude(claudeCli, launchArgs, envVars);
|
|
} else {
|
|
// DEFAULT: No profile configured, use Claude's own defaults
|
|
// Skip WebSearch hook - native Claude has server-side WebSearch
|
|
// Skip Image Analyzer hook - native Claude has native vision support
|
|
const envVars: NodeJS.ProcessEnv = {
|
|
CCS_PROFILE_TYPE: 'default',
|
|
CCS_WEBSEARCH_SKIP: '1',
|
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
|
};
|
|
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
|
const browserProfileDir =
|
|
resolvedTarget === 'claude'
|
|
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
|
|
: undefined;
|
|
|
|
if (resolvedTarget === 'claude') {
|
|
if (browserProfileDir) {
|
|
ensureBrowserMcpOrThrow();
|
|
browserRuntimeEnv = {
|
|
...(await resolveBrowserRuntimeEnv({
|
|
profileDir: browserProfileDir,
|
|
})),
|
|
};
|
|
Object.assign(envVars, browserRuntimeEnv);
|
|
}
|
|
const defaultContinuityInheritance = await resolveProfileContinuityInheritance({
|
|
profileName: profileInfo.name,
|
|
profileType: profileInfo.type,
|
|
target: resolvedTarget,
|
|
});
|
|
if (defaultContinuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
|
console.error(
|
|
info(
|
|
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${defaultContinuityInheritance.sourceAccount}"`
|
|
)
|
|
);
|
|
}
|
|
if (defaultContinuityInheritance.claudeConfigDir) {
|
|
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
|
|
if (
|
|
browserProfileDir &&
|
|
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
|
|
) {
|
|
throw new Error(
|
|
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dispatch through target adapter for non-claude targets
|
|
if (resolvedTarget !== 'claude') {
|
|
const adapter = targetAdapter;
|
|
if (!adapter) {
|
|
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
|
process.exit(1);
|
|
}
|
|
if (!adapter.supportsProfileType('default')) {
|
|
console.error(fail(`${adapter.displayName} does not support default profile mode`));
|
|
process.exit(1);
|
|
}
|
|
const creds: TargetCredentials = {
|
|
profile: 'default',
|
|
baseUrl: process.env['ANTHROPIC_BASE_URL'] || '',
|
|
apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '',
|
|
model: process.env['ANTHROPIC_MODEL'],
|
|
provider: resolveDroidProvider({
|
|
provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'],
|
|
baseUrl: process.env['ANTHROPIC_BASE_URL'],
|
|
model: process.env['ANTHROPIC_MODEL'],
|
|
}),
|
|
reasoningOverride: runtimeReasoningOverride,
|
|
runtimeConfigOverrides: codexRuntimeConfigOverrides,
|
|
browserRuntimeEnv,
|
|
};
|
|
if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) {
|
|
console.error(
|
|
fail(
|
|
`${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
|
|
)
|
|
);
|
|
console.error(info('Use a settings-based profile instead: ccs glm --target droid'));
|
|
process.exit(1);
|
|
}
|
|
await adapter.prepareCredentials(creds);
|
|
const targetArgs = adapter.buildArgs('default', targetRemainingArgs, {
|
|
creds,
|
|
profileType: 'default',
|
|
binaryInfo: targetBinaryInfo || undefined,
|
|
});
|
|
const targetEnv = adapter.buildEnv(creds, 'default');
|
|
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
|
return;
|
|
}
|
|
|
|
const launchArgs = resolveNativeClaudeLaunchArgs(
|
|
browserRuntimeEnv ? appendBrowserToolArgs(remainingArgs) : remainingArgs,
|
|
'default',
|
|
envVars.CLAUDE_CONFIG_DIR
|
|
);
|
|
execClaude(claudeCli, launchArgs, envVars);
|
|
}
|
|
} catch (error) {
|
|
const err = error as ProfileError;
|
|
// Check if this is a profile not found error with suggestions
|
|
if (err.profileName && err.availableProfiles !== undefined) {
|
|
const allProfiles = err.availableProfiles.split('\n');
|
|
await ErrorManager.showProfileNotFound(err.profileName, allProfiles, err.suggestions);
|
|
} else {
|
|
console.error(fail(err.message));
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ========== Global Error Handlers ==========
|
|
|
|
// Handle uncaught exceptions
|
|
process.on('uncaughtException', (error: Error) => {
|
|
handleError(error);
|
|
});
|
|
|
|
// Handle unhandled promise rejections
|
|
process.on('unhandledRejection', (reason: unknown) => {
|
|
handleError(reason);
|
|
});
|
|
|
|
// Handle process termination signals for cleanup
|
|
process.on('SIGTERM', () => {
|
|
try {
|
|
runCleanup();
|
|
} catch {
|
|
// Cleanup failure should not block termination.
|
|
}
|
|
// If a target exec path registered additional signal listeners, let those
|
|
// listeners forward/coordinate child shutdown and final exit codes.
|
|
if (process.listenerCount('SIGTERM') <= 1) {
|
|
process.exit(143); // 128 + SIGTERM(15)
|
|
}
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
try {
|
|
runCleanup();
|
|
} catch {
|
|
// Cleanup failure should not block termination.
|
|
}
|
|
// Same coordination rule as SIGTERM.
|
|
if (process.listenerCount('SIGINT') <= 1) {
|
|
process.exit(130); // 128 + SIGINT(2)
|
|
}
|
|
});
|
|
|
|
// Run main
|
|
main().catch(handleError);
|