mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 07:12:18 +00:00
467 lines
15 KiB
TypeScript
467 lines
15 KiB
TypeScript
/**
|
|
* CLIProxy Variant Settings File Manager
|
|
*
|
|
* Handles creation of settings.json files for CLIProxy variants.
|
|
* These files contain environment variables for Claude CLI integration.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { CLIProxyProfileName } from '../../auth/profile-detector';
|
|
import { getCcsDir } from '../../utils/config-manager';
|
|
import { expandPath } from '../../utils/helpers';
|
|
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
|
|
import { CLIProxyProvider } from '../types';
|
|
import { CompositeTierConfig } from '../../config/unified-config-types';
|
|
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
|
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
|
|
import {
|
|
ensureProfileHooks as ensureImageAnalyzerHooks,
|
|
removeImageAnalysisProfileHook,
|
|
} from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
|
import { prepareImageAnalysisFallbackHook } from '../../utils/hooks';
|
|
import { getEffectiveApiKey } from '../auth-token-manager';
|
|
import { warn } from '../../utils/ui';
|
|
import { normalizeModelIdForProvider } from '../model-id-normalizer';
|
|
|
|
/** Environment settings structure */
|
|
interface SettingsEnv {
|
|
[key: string]: string;
|
|
ANTHROPIC_BASE_URL: string;
|
|
ANTHROPIC_AUTH_TOKEN: string;
|
|
ANTHROPIC_MODEL: string;
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: string;
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: string;
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
|
|
}
|
|
|
|
interface SettingsFile {
|
|
env: Record<string, string>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
function canonicalizeModelForProvider(
|
|
provider: CLIProxyProfileName | undefined,
|
|
model: string
|
|
): string {
|
|
if (!provider) return model;
|
|
return normalizeModelIdForProvider(model, provider);
|
|
}
|
|
|
|
/**
|
|
* Build settings env object for a variant
|
|
*/
|
|
function buildSettingsEnv(
|
|
provider: CLIProxyProfileName,
|
|
model: string,
|
|
port: number = CLIPROXY_DEFAULT_PORT
|
|
): SettingsEnv {
|
|
const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, port);
|
|
const normalizedModel = canonicalizeModelForProvider(provider, model);
|
|
const defaultModel = normalizedModel;
|
|
const opusModel = normalizedModel;
|
|
const sonnetModel = normalizedModel;
|
|
const haikuModel = canonicalizeModelForProvider(
|
|
provider,
|
|
baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model
|
|
);
|
|
|
|
return {
|
|
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
|
|
ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
|
|
ANTHROPIC_MODEL: defaultModel,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ensure directory exists
|
|
*/
|
|
function ensureDir(dir: string): void {
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write settings file atomically using temp file + rename.
|
|
* The renameSync is atomic on POSIX systems, preventing partial writes on crash.
|
|
*/
|
|
function writeSettings(filePath: string, settings: SettingsFile): void {
|
|
const tempPath = `${filePath}.tmp.${process.pid}`;
|
|
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
fs.renameSync(tempPath, filePath);
|
|
}
|
|
|
|
function rollbackSettingsFile(
|
|
filePath: string,
|
|
previousContent: string | null,
|
|
existedBefore: boolean
|
|
): void {
|
|
if (existedBefore && previousContent !== null) {
|
|
fs.writeFileSync(filePath, previousContent, 'utf8');
|
|
return;
|
|
}
|
|
|
|
if (fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get settings file path for a variant
|
|
*/
|
|
export function getSettingsFilePath(provider: CLIProxyProfileName, name: string): string {
|
|
const ccsDir = getCcsDir();
|
|
return path.join(ccsDir, `${provider}-${name}.settings.json`);
|
|
}
|
|
|
|
/**
|
|
* Get settings file name (without path)
|
|
*/
|
|
export function getSettingsFileName(provider: CLIProxyProfileName, name: string): string {
|
|
return `${provider}-${name}.settings.json`;
|
|
}
|
|
|
|
/**
|
|
* Get relative settings path with ~ prefix
|
|
*/
|
|
export function getRelativeSettingsPath(provider: CLIProxyProfileName, name: string): string {
|
|
return `~/.ccs/${getSettingsFileName(provider, name)}`;
|
|
}
|
|
|
|
/**
|
|
* Create settings.json file for CLIProxy variant (legacy mode)
|
|
*/
|
|
export function createSettingsFile(
|
|
name: string,
|
|
provider: CLIProxyProfileName,
|
|
model: string,
|
|
port: number = CLIPROXY_DEFAULT_PORT
|
|
): string {
|
|
const ccsDir = getCcsDir();
|
|
const settingsPath = getSettingsFilePath(provider, name);
|
|
|
|
const settings: SettingsFile = {
|
|
env: buildSettingsEnv(provider, model, port),
|
|
};
|
|
|
|
const settingsExisted = fs.existsSync(settingsPath);
|
|
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
|
|
ensureDir(ccsDir);
|
|
writeSettings(settingsPath, settings);
|
|
|
|
try {
|
|
ensureWebSearchMcpOrThrow();
|
|
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
|
if (imageAnalysisMcpReady) {
|
|
removeImageAnalysisProfileHook(`${provider}-${name}`, settingsPath);
|
|
} else {
|
|
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
|
ensureImageAnalyzerHooks({
|
|
profileName: `${provider}-${name}`,
|
|
profileType: 'cliproxy',
|
|
cliproxyProvider: provider,
|
|
settingsPath,
|
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
|
throw error;
|
|
}
|
|
|
|
return settingsPath;
|
|
}
|
|
|
|
/**
|
|
* Create settings.json file for CLIProxy variant (unified mode)
|
|
*/
|
|
export function createSettingsFileUnified(
|
|
name: string,
|
|
provider: CLIProxyProfileName,
|
|
model: string,
|
|
port: number = CLIPROXY_DEFAULT_PORT
|
|
): string {
|
|
const ccsDir = getCcsDir(); // Use centralized function for CCS_HOME support
|
|
const settingsPath = path.join(ccsDir, getSettingsFileName(provider, name));
|
|
|
|
const settings: SettingsFile = {
|
|
env: buildSettingsEnv(provider, model, port),
|
|
};
|
|
|
|
const settingsExisted = fs.existsSync(settingsPath);
|
|
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
|
|
ensureDir(ccsDir);
|
|
writeSettings(settingsPath, settings);
|
|
|
|
try {
|
|
ensureWebSearchMcpOrThrow();
|
|
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
|
if (imageAnalysisMcpReady) {
|
|
removeImageAnalysisProfileHook(`${provider}-${name}`, settingsPath);
|
|
} else {
|
|
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
|
ensureImageAnalyzerHooks({
|
|
profileName: `${provider}-${name}`,
|
|
profileType: 'cliproxy',
|
|
cliproxyProvider: provider,
|
|
settingsPath,
|
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
|
throw error;
|
|
}
|
|
|
|
return settingsPath;
|
|
}
|
|
|
|
/**
|
|
* Build settings env object for a composite variant.
|
|
* Uses root URL (no /api/provider/ path) for model-based routing.
|
|
*/
|
|
function buildCompositeSettingsEnv(
|
|
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
|
|
defaultTier: 'opus' | 'sonnet' | 'haiku',
|
|
port: number = CLIPROXY_DEFAULT_PORT
|
|
): SettingsEnv {
|
|
const defaultModel = tiers[defaultTier].model;
|
|
|
|
return {
|
|
// Root URL — CLIProxyAPI routes based on model name, no provider prefix
|
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
|
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
|
|
ANTHROPIC_MODEL: defaultModel,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: tiers.opus.model,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: tiers.sonnet.model,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: tiers.haiku.model,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create settings.json file for a composite variant.
|
|
*/
|
|
export function createCompositeSettingsFile(
|
|
name: string,
|
|
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
|
|
defaultTier: 'opus' | 'sonnet' | 'haiku',
|
|
port: number = CLIPROXY_DEFAULT_PORT,
|
|
settingsPathOverride?: string
|
|
): string {
|
|
const ccsDir = getCcsDir();
|
|
const defaultSettingsPath = path.join(ccsDir, `composite-${name}.settings.json`);
|
|
const settingsPath = settingsPathOverride
|
|
? (() => {
|
|
const expanded = expandPath(settingsPathOverride);
|
|
return path.isAbsolute(expanded) ? expanded : path.join(ccsDir, expanded);
|
|
})()
|
|
: defaultSettingsPath;
|
|
const settingsDir = path.dirname(settingsPath);
|
|
|
|
const coreEnv = buildCompositeSettingsEnv(tiers, defaultTier, port);
|
|
let settings: SettingsFile = { env: coreEnv };
|
|
|
|
// Preserve non-core env vars and non-env fields (hooks/presets/etc.) when regenerating.
|
|
if (fs.existsSync(settingsPath)) {
|
|
try {
|
|
const content = fs.readFileSync(settingsPath, 'utf-8');
|
|
const parsed = JSON.parse(content) as SettingsFile;
|
|
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
const existingEnv =
|
|
parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
|
? (parsed.env as Record<string, string>)
|
|
: {};
|
|
const {
|
|
ANTHROPIC_BASE_URL: _baseUrl,
|
|
ANTHROPIC_AUTH_TOKEN: _authToken,
|
|
ANTHROPIC_MODEL: _model,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: _opus,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku,
|
|
...extraEnv
|
|
} = existingEnv;
|
|
|
|
settings = {
|
|
...parsed,
|
|
env: {
|
|
...extraEnv,
|
|
...coreEnv,
|
|
},
|
|
};
|
|
}
|
|
} catch {
|
|
// Invalid JSON — overwrite with a clean settings object.
|
|
}
|
|
}
|
|
|
|
const settingsExisted = fs.existsSync(settingsPath);
|
|
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
|
|
ensureDir(settingsDir);
|
|
writeSettings(settingsPath, settings);
|
|
|
|
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
|
|
try {
|
|
ensureWebSearchMcpOrThrow();
|
|
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
|
if (imageAnalysisMcpReady) {
|
|
removeImageAnalysisProfileHook(`composite-${name}`, settingsPath);
|
|
} else {
|
|
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
|
ensureImageAnalyzerHooks({
|
|
profileName: `composite-${name}`,
|
|
profileType: 'cliproxy',
|
|
cliproxyProvider: tiers[defaultTier].provider,
|
|
isComposite: true,
|
|
settingsPath,
|
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
return settingsPath;
|
|
}
|
|
|
|
/**
|
|
* Get relative settings path for a composite variant
|
|
*/
|
|
export function getCompositeRelativeSettingsPath(name: string): string {
|
|
return `~/.ccs/composite-${name}.settings.json`;
|
|
}
|
|
|
|
/**
|
|
* Delete settings file if it exists.
|
|
* Uses expandPath() for cross-platform path handling.
|
|
*/
|
|
export function deleteSettingsFile(settingsPath: string): boolean {
|
|
const resolvedPath = expandPath(settingsPath);
|
|
if (fs.existsSync(resolvedPath)) {
|
|
fs.unlinkSync(resolvedPath);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Update model in an existing settings file
|
|
*/
|
|
export function updateSettingsModel(
|
|
settingsPath: string,
|
|
model: string,
|
|
provider?: CLIProxyProfileName,
|
|
options?: {
|
|
rewriteHaikuModel?: (model: string) => string;
|
|
}
|
|
): void {
|
|
const fileName = path.basename(settingsPath);
|
|
if (fileName.startsWith('composite-')) {
|
|
console.log(
|
|
warn('Cannot update model for composite variant. Edit config.yaml tiers directly.')
|
|
);
|
|
return;
|
|
}
|
|
|
|
const resolvedPath = settingsPath.replace(/^~/, os.homedir());
|
|
if (!fs.existsSync(resolvedPath)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
const settings = JSON.parse(content) as SettingsFile;
|
|
|
|
if (model) {
|
|
settings.env = settings.env || ({} as SettingsEnv);
|
|
const normalizedModel = canonicalizeModelForProvider(provider, model);
|
|
settings.env.ANTHROPIC_MODEL = normalizedModel;
|
|
settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizedModel;
|
|
settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizedModel;
|
|
if (provider === 'codex' && settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
|
|
const normalizedHaikuModel = canonicalizeModelForProvider(
|
|
provider,
|
|
settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
|
);
|
|
settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = options?.rewriteHaikuModel
|
|
? options.rewriteHaikuModel(normalizedHaikuModel)
|
|
: normalizedHaikuModel;
|
|
}
|
|
} else {
|
|
// Clear model settings to use defaults
|
|
delete (settings.env as unknown as Record<string, string>).ANTHROPIC_MODEL;
|
|
}
|
|
|
|
const tempPath = `${resolvedPath}.tmp.${process.pid}`;
|
|
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
fs.renameSync(tempPath, resolvedPath);
|
|
} catch {
|
|
// Ignore errors - settings file may be invalid
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update provider + model core env vars in an existing single-provider settings file.
|
|
* Preserves non-core env vars and top-level settings keys (hooks, presets, etc.).
|
|
*/
|
|
export function updateSettingsProviderAndModel(
|
|
settingsPath: string,
|
|
provider: CLIProxyProfileName,
|
|
model: string,
|
|
port: number = CLIPROXY_DEFAULT_PORT
|
|
): void {
|
|
const resolvedPath = expandPath(settingsPath);
|
|
const fileName = path.basename(resolvedPath);
|
|
if (fileName.startsWith('composite-')) {
|
|
console.log(
|
|
warn('Cannot update provider/model for composite variant. Edit config.yaml tiers directly.')
|
|
);
|
|
return;
|
|
}
|
|
|
|
const coreEnv = buildSettingsEnv(provider, model, port);
|
|
let settings: SettingsFile = { env: coreEnv };
|
|
|
|
if (fs.existsSync(resolvedPath)) {
|
|
try {
|
|
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
const parsed = JSON.parse(content) as SettingsFile;
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
const existingEnv =
|
|
parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
|
? (parsed.env as Record<string, string>)
|
|
: {};
|
|
const {
|
|
ANTHROPIC_BASE_URL: _baseUrl,
|
|
ANTHROPIC_AUTH_TOKEN: _authToken,
|
|
ANTHROPIC_MODEL: _model,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: _opus,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku,
|
|
...extraEnv
|
|
} = existingEnv;
|
|
|
|
settings = {
|
|
...parsed,
|
|
env: {
|
|
...extraEnv,
|
|
...coreEnv,
|
|
},
|
|
};
|
|
}
|
|
} catch {
|
|
// Keep default and overwrite malformed file.
|
|
}
|
|
}
|
|
|
|
ensureDir(path.dirname(resolvedPath));
|
|
writeSettings(resolvedPath, settings);
|
|
}
|