Files
ccs/src/web-server/routes.ts
T
kaitranntt c0938e1c82 feat(websearch): add Grok CLI support and improve install guidance
- Add Grok CLI detection (grok-4-cli by lalomorales22) alongside Gemini CLI
- Add WebSearch health check group to health-service.ts
- Update settings UI to show both Gemini and Grok CLI providers
- Add detailed install hints when no WebSearch CLI is installed
- Update API routes to return grokCli status
- Both CLI and dashboard users now see clear installation guidance

Providers:
- Gemini CLI: FREE tier (1000 req/day), no API key needed
- Grok CLI: Requires xAI API key (XAI_API_KEY), web + X search
2025-12-16 21:00:35 -05:00

1529 lines
42 KiB
TypeScript

/**
* REST API Routes (Phase 03)
*
* Implements CRUD operations for profiles, cliproxy variants, and accounts.
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../utils/config-manager';
import { Config, Settings } from '../types/config';
import { expandPath } from '../utils/helpers';
import { runHealthChecks, fixHealthIssue } from './health-service';
import {
getAllAuthStatus,
getOAuthConfig,
initializeAccounts,
triggerOAuth,
} from '../cliproxy/auth-handler';
import {
fetchCliproxyStats,
fetchCliproxyModels,
isCliproxyRunning,
} from '../cliproxy/stats-fetcher';
import {
listOpenAICompatProviders,
getOpenAICompatProvider,
addOpenAICompatProvider,
updateOpenAICompatProvider,
removeOpenAICompatProvider,
OPENROUTER_TEMPLATE,
TOGETHER_TEMPLATE,
} from '../cliproxy/openai-compat-manager';
import {
getAllAccountsSummary,
getProviderAccounts,
setDefaultAccount as setDefaultAccountFn,
removeAccount as removeAccountFn,
} from '../cliproxy/account-manager';
import type { CLIProxyProvider } from '../cliproxy/types';
import { getClaudeEnvVars } from '../cliproxy/config-generator';
// Unified config imports
import {
hasUnifiedConfig,
loadUnifiedConfig,
saveUnifiedConfig,
getConfigFormat,
getConfigYamlPath,
} from '../config/unified-config-loader';
import {
needsMigration,
migrate,
rollback,
getBackupDirectories,
} from '../config/migration-manager';
import { getProfileSecrets, setProfileSecrets } from '../config/secrets-manager';
import { getWebSearchConfig } from '../config/unified-config-loader';
import type { WebSearchConfig } from '../config/unified-config-types';
import { isUnifiedConfig } from '../config/unified-config-types';
import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys';
import {
getWebSearchReadiness,
getGeminiCliStatus,
getGrokCliStatus,
} from '../utils/websearch-manager';
export const apiRoutes = Router();
/**
* Helper: Read config safely with fallback
*/
function readConfigSafe(): Config {
try {
return loadConfig();
} catch {
return { profiles: {} };
}
}
/**
* Helper: Write config atomically
*/
function writeConfig(config: Config): void {
const configPath = getConfigPath();
const tempPath = configPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n');
fs.renameSync(tempPath, configPath);
}
/**
* Helper: Check if profile is configured (has valid settings file)
*/
function isConfigured(profileName: string, config: Config): boolean {
const settingsPath = config.profiles[profileName];
if (!settingsPath) return false;
try {
const expandedPath = expandPath(settingsPath);
if (!fs.existsSync(expandedPath)) return false;
const settings = loadSettings(expandedPath);
return !!(settings.env?.ANTHROPIC_BASE_URL && settings.env?.ANTHROPIC_AUTH_TOKEN);
} catch {
return false;
}
}
/** Model mapping for API profiles */
interface ModelMapping {
model?: string;
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
}
/**
* Helper: Create settings file for profile
*/
function createSettingsFile(
name: string,
baseUrl: string,
apiKey: string,
models: ModelMapping = {}
): string {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
const { model, opusModel, sonnetModel, haikuModel } = models;
const settings: Settings = {
env: {
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: apiKey,
...(model && { ANTHROPIC_MODEL: model }),
...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }),
...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }),
...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }),
},
};
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
return `~/.ccs/${name}.settings.json`;
}
/**
* Helper: Update settings file
*/
function updateSettingsFile(
name: string,
updates: {
baseUrl?: string;
apiKey?: string;
model?: string;
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
}
): void {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (!fs.existsSync(settingsPath)) {
throw new Error('Settings file not found');
}
const settings = loadSettings(settingsPath);
if (updates.baseUrl) {
settings.env = settings.env || {};
settings.env.ANTHROPIC_BASE_URL = updates.baseUrl;
}
if (updates.apiKey) {
settings.env = settings.env || {};
settings.env.ANTHROPIC_AUTH_TOKEN = updates.apiKey;
}
if (updates.model !== undefined) {
settings.env = settings.env || {};
if (updates.model) {
settings.env.ANTHROPIC_MODEL = updates.model;
} else {
delete settings.env.ANTHROPIC_MODEL;
}
}
// Handle model mapping fields
if (updates.opusModel !== undefined) {
settings.env = settings.env || {};
if (updates.opusModel) {
settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = updates.opusModel;
} else {
delete settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
}
}
if (updates.sonnetModel !== undefined) {
settings.env = settings.env || {};
if (updates.sonnetModel) {
settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = updates.sonnetModel;
} else {
delete settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
}
}
if (updates.haikuModel !== undefined) {
settings.env = settings.env || {};
if (updates.haikuModel) {
settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = updates.haikuModel;
} else {
delete settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
}
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
/**
* Helper: Create cliproxy variant settings
* Includes base URL and auth token for proper Claude CLI integration
*/
function createCliproxySettings(name: string, provider: CLIProxyProvider, model?: string): string {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
// Get base env vars from provider config (includes BASE_URL, AUTH_TOKEN)
const baseEnv = getClaudeEnvVars(provider);
const settings: Settings = {
env: {
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
ANTHROPIC_MODEL: model || (baseEnv.ANTHROPIC_MODEL as string) || '',
ANTHROPIC_DEFAULT_OPUS_MODEL: model || (baseEnv.ANTHROPIC_DEFAULT_OPUS_MODEL as string) || '',
ANTHROPIC_DEFAULT_SONNET_MODEL:
model || (baseEnv.ANTHROPIC_DEFAULT_SONNET_MODEL as string) || '',
ANTHROPIC_DEFAULT_HAIKU_MODEL:
(baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL as string) || model || '',
},
};
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
return `~/.ccs/${name}.settings.json`;
}
// ==================== Profile CRUD ====================
/**
* GET /api/profiles - List all profiles
*/
apiRoutes.get('/profiles', (_req: Request, res: Response) => {
const config = readConfigSafe();
const profiles = Object.entries(config.profiles).map(([name, settingsPath]) => ({
name,
settingsPath,
configured: isConfigured(name, config),
}));
res.json({ profiles });
});
/**
* POST /api/profiles - Create new profile
*/
apiRoutes.post('/profiles', (req: Request, res: Response): void => {
const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body;
if (!name || !baseUrl || !apiKey) {
res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' });
return;
}
const config = readConfigSafe();
if (config.profiles[name]) {
res.status(409).json({ error: 'Profile already exists' });
return;
}
// Ensure .ccs directory exists
if (!fs.existsSync(getCcsDir())) {
fs.mkdirSync(getCcsDir(), { recursive: true });
}
// Create settings file with model mapping
const settingsPath = createSettingsFile(name, baseUrl, apiKey, {
model,
opusModel,
sonnetModel,
haikuModel,
});
// Update config
config.profiles[name] = settingsPath;
writeConfig(config);
res.status(201).json({ name, settingsPath });
});
/**
* PUT /api/profiles/:name - Update profile
*/
apiRoutes.put('/profiles/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body;
const config = readConfigSafe();
if (!config.profiles[name]) {
res.status(404).json({ error: 'Profile not found' });
return;
}
try {
updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel });
res.json({ name, updated: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* DELETE /api/profiles/:name - Delete profile
*/
apiRoutes.delete('/profiles/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const config = readConfigSafe();
if (!config.profiles[name]) {
res.status(404).json({ error: 'Profile not found' });
return;
}
// Delete settings file
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
// Remove from config
delete config.profiles[name];
writeConfig(config);
res.json({ name, deleted: true });
});
// ==================== CLIProxy CRUD ====================
/**
* GET /api/cliproxy - List cliproxy variants
*/
apiRoutes.get('/cliproxy', (_req: Request, res: Response) => {
const config = readConfigSafe();
const variants = Object.entries(config.cliproxy || {}).map(([name, variant]) => ({
name,
provider: variant.provider,
settings: variant.settings,
account: variant.account || 'default', // Include account field
}));
res.json({ variants });
});
/**
* POST /api/cliproxy - Create cliproxy variant
*/
apiRoutes.post('/cliproxy', (req: Request, res: Response): void => {
const { name, provider, model, account } = req.body;
if (!name || !provider) {
res.status(400).json({ error: 'Missing required fields: name, provider' });
return;
}
const config = readConfigSafe();
config.cliproxy = config.cliproxy || {};
if (config.cliproxy[name]) {
res.status(409).json({ error: 'Variant already exists' });
return;
}
// Ensure .ccs directory exists
if (!fs.existsSync(getCcsDir())) {
fs.mkdirSync(getCcsDir(), { recursive: true });
}
// Create settings file for variant
const settingsPath = createCliproxySettings(name, provider as CLIProxyProvider, model);
// Include account if specified (defaults to 'default' if not provided)
config.cliproxy[name] = {
provider,
settings: settingsPath,
...(account && { account }),
};
writeConfig(config);
res.status(201).json({ name, provider, settings: settingsPath, account: account || 'default' });
});
/**
* PUT /api/cliproxy/:name - Update cliproxy variant
*/
apiRoutes.put('/cliproxy/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const { provider, account, model } = req.body;
const config = readConfigSafe();
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
return;
}
const variant = config.cliproxy[name];
// Update fields if provided
if (provider) {
variant.provider = provider;
}
if (account !== undefined) {
if (account) {
variant.account = account;
} else {
delete variant.account; // Remove account to use default
}
}
// Update model in settings file if provided
if (model !== undefined) {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
const settings = loadSettings(settingsPath);
if (model) {
settings.env = settings.env || {};
settings.env.ANTHROPIC_MODEL = model;
} else if (settings.env) {
delete settings.env.ANTHROPIC_MODEL;
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
}
writeConfig(config);
res.json({
name,
provider: variant.provider,
account: variant.account || 'default',
settings: variant.settings,
updated: true,
});
});
/**
* DELETE /api/cliproxy/:name - Delete cliproxy variant
*/
apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const config = readConfigSafe();
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
return;
}
// Delete settings file
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
delete config.cliproxy[name];
writeConfig(config);
res.json({ name, deleted: true });
});
/**
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
*/
apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => {
// Initialize accounts from existing tokens on first request
initializeAccounts();
const statuses = getAllAuthStatus();
const authStatus = statuses.map((status) => {
const oauthConfig = getOAuthConfig(status.provider);
return {
provider: status.provider,
displayName: oauthConfig.displayName,
authenticated: status.authenticated,
lastAuth: status.lastAuth?.toISOString() || null,
tokenFiles: status.tokenFiles.length,
accounts: status.accounts,
defaultAccount: status.defaultAccount,
};
});
res.json({ authStatus });
});
// ==================== Account Management (Multi-Account Support) ====================
/**
* GET /api/cliproxy/accounts - Get all accounts across all providers
*/
apiRoutes.get('/cliproxy/accounts', (_req: Request, res: Response) => {
// Initialize accounts from existing tokens
initializeAccounts();
const accounts = getAllAccountsSummary();
res.json({ accounts });
});
/**
* GET /api/cliproxy/accounts/:provider - Get accounts for a specific provider
*/
apiRoutes.get('/cliproxy/accounts/:provider', (req: Request, res: Response): void => {
const { provider } = req.params;
// Validate provider
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
const accounts = getProviderAccounts(provider as CLIProxyProvider);
res.json({ provider, accounts });
});
/**
* POST /api/cliproxy/accounts/:provider/default - Set default account for provider
*/
apiRoutes.post('/cliproxy/accounts/:provider/default', (req: Request, res: Response): void => {
const { provider } = req.params;
const { accountId } = req.body;
if (!accountId) {
res.status(400).json({ error: 'Missing required field: accountId' });
return;
}
// Validate provider
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId);
if (success) {
res.json({ provider, defaultAccount: accountId });
} else {
res.status(404).json({ error: 'Account not found' });
}
});
/**
* DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account
*/
apiRoutes.delete('/cliproxy/accounts/:provider/:accountId', (req: Request, res: Response): void => {
const { provider, accountId } = req.params;
// Validate provider
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
if (success) {
res.json({ provider, accountId, deleted: true });
} else {
res.status(404).json({ error: 'Account not found' });
}
});
/**
* POST /api/cliproxy/auth/:provider/start - Start OAuth flow for a provider
* Opens browser for authentication and returns account info when complete
*/
apiRoutes.post(
'/cliproxy/auth/:provider/start',
async (req: Request, res: Response): Promise<void> => {
const { provider } = req.params;
const { nickname } = req.body;
// Validate provider
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
try {
// Trigger OAuth flow - this opens browser and waits for completion
const account = await triggerOAuth(provider as CLIProxyProvider, {
add: true, // Always add mode from UI
headless: false, // Force interactive mode
nickname: nickname || undefined,
});
if (account) {
res.json({
success: true,
account: {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
},
});
} else {
res.status(400).json({ error: 'Authentication failed or was cancelled' });
}
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
}
);
// ==================== Settings (Phase 05) ====================
/**
* Helper: Mask API keys in settings
*/
function maskApiKeys(settings: Settings): Settings {
if (!settings.env) return settings;
const masked = { ...settings, env: { ...settings.env } };
for (const key of Object.keys(masked.env)) {
if (isSensitiveKey(key)) {
masked.env[key] = maskSensitiveValue(masked.env[key]);
}
}
return masked;
}
/**
* GET /api/settings/:profile - Get settings with masked API keys
*/
apiRoutes.get('/settings/:profile', (req: Request, res: Response): void => {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath);
// Mask API keys in response
const masked = maskApiKeys(settings);
res.json({
profile,
settings: masked,
mtime: stat.mtime.getTime(),
path: settingsPath,
});
});
/**
* GET /api/settings/:profile/raw - Get full settings (for editing)
*/
apiRoutes.get('/settings/:profile/raw', (req: Request, res: Response): void => {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath);
res.json({
profile,
settings,
mtime: stat.mtime.getTime(),
path: settingsPath,
});
});
/**
* PUT /api/settings/:profile - Update settings with conflict detection and backup
*/
apiRoutes.put('/settings/:profile', (req: Request, res: Response): void => {
const { profile } = req.params;
const { settings, expectedMtime } = req.body;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
// Conflict detection
const stat = fs.statSync(settingsPath);
if (expectedMtime && stat.mtime.getTime() !== expectedMtime) {
res.status(409).json({
error: 'File modified externally',
currentMtime: stat.mtime.getTime(),
});
return;
}
// Create backup
const backupDir = path.join(ccsDir, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
fs.copyFileSync(settingsPath, backupPath);
// Write new settings atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
const newStat = fs.statSync(settingsPath);
res.json({
profile,
mtime: newStat.mtime.getTime(),
backupPath,
});
});
// ==================== Presets ====================
/**
* GET /api/settings/:profile/presets - Get saved presets for a provider
*/
apiRoutes.get('/settings/:profile/presets', (req: Request, res: Response): void => {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.json({ presets: [] });
return;
}
const settings = loadSettings(settingsPath);
res.json({ presets: settings.presets || [] });
});
/**
* POST /api/settings/:profile/presets - Create a new preset
*/
apiRoutes.post('/settings/:profile/presets', (req: Request, res: Response): void => {
const { profile } = req.params;
const { name, default: defaultModel, opus, sonnet, haiku } = req.body;
if (!name || !defaultModel) {
res.status(400).json({ error: 'Missing required fields: name, default' });
return;
}
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
// Create settings file if it doesn't exist
if (!fs.existsSync(settingsPath)) {
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
}
const settings = loadSettings(settingsPath);
settings.presets = settings.presets || [];
// Check for duplicate name
if (settings.presets.some((p) => p.name === name)) {
res.status(409).json({ error: 'Preset with this name already exists' });
return;
}
const preset = {
name,
default: defaultModel,
opus: opus || defaultModel,
sonnet: sonnet || defaultModel,
haiku: haiku || defaultModel,
};
settings.presets.push(preset);
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
res.status(201).json({ preset });
});
/**
* DELETE /api/settings/:profile/presets/:name - Delete a preset
*/
apiRoutes.delete('/settings/:profile/presets/:name', (req: Request, res: Response): void => {
const { profile, name } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
const settings = loadSettings(settingsPath);
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
res.status(404).json({ error: 'Preset not found' });
return;
}
settings.presets = settings.presets.filter((p) => p.name !== name);
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
res.json({ success: true });
});
// ==================== Accounts ====================
/**
* GET /api/accounts - List accounts from profiles.json
*/
apiRoutes.get('/accounts', (_req: Request, res: Response): void => {
const profilesPath = path.join(getCcsDir(), 'profiles.json');
if (!fs.existsSync(profilesPath)) {
res.json({ accounts: [], default: null });
return;
}
const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => {
// Type-safe handling of metadata
const metadata = meta as Record<string, unknown>;
return {
name,
...metadata,
};
});
res.json({ accounts, default: data.default || null });
});
/**
* POST /api/accounts/default - Set default account
*/
apiRoutes.post('/accounts/default', (req: Request, res: Response): void => {
const { name } = req.body;
if (!name) {
res.status(400).json({ error: 'Missing required field: name' });
return;
}
const profilesPath = path.join(getCcsDir(), 'profiles.json');
const data = fs.existsSync(profilesPath)
? JSON.parse(fs.readFileSync(profilesPath, 'utf8'))
: { profiles: {} };
data.default = name;
fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n');
res.json({ default: name });
});
// ==================== Health (Phase 06) ====================
/**
* GET /api/health - Run health checks
*/
apiRoutes.get('/health', async (_req: Request, res: Response) => {
const report = await runHealthChecks();
res.json(report);
});
/**
* POST /api/health/fix/:checkId - Fix a health issue
*/
apiRoutes.post('/health/fix/:checkId', (req: Request, res: Response): void => {
const { checkId } = req.params;
const result = fixHealthIssue(checkId);
if (result.success) {
res.json({ success: true, message: result.message });
} else {
res.status(400).json({ success: false, message: result.message });
}
});
// ==================== Unified Config (Phase 5) ====================
/**
* GET /api/config/format - Return current config format and migration status
*/
apiRoutes.get('/config/format', (_req: Request, res: Response) => {
res.json({
format: getConfigFormat(),
migrationNeeded: needsMigration(),
backups: getBackupDirectories(),
});
});
/**
* GET /api/config - Return unified config (excludes secrets)
*/
apiRoutes.get('/config', (_req: Request, res: Response): void => {
if (!hasUnifiedConfig()) {
res.status(400).json({ error: 'Unified config not enabled' });
return;
}
const config = loadUnifiedConfig();
if (!config) {
res.status(500).json({ error: 'Failed to load config' });
return;
}
res.json(config);
});
/**
* GET /api/config/raw - Return raw YAML content for display
*/
apiRoutes.get('/config/raw', (_req: Request, res: Response): void => {
const yamlPath = getConfigYamlPath();
if (!fs.existsSync(yamlPath)) {
res.status(404).json({ error: 'Config file not found' });
return;
}
try {
const content = fs.readFileSync(yamlPath, 'utf8');
res.type('text/plain').send(content);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
/**
* PUT /api/config - Update unified config
*/
apiRoutes.put('/config', (req: Request, res: Response): void => {
const config = req.body;
if (!isUnifiedConfig(config)) {
res.status(400).json({ error: 'Invalid config format' });
return;
}
try {
saveUnifiedConfig(config);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
/**
* POST /api/config/migrate - Trigger migration from JSON to YAML
*/
apiRoutes.post('/config/migrate', async (req: Request, res: Response) => {
const dryRun = req.query.dryRun === 'true';
const result = await migrate(dryRun);
res.json(result);
});
/**
* POST /api/config/rollback - Rollback migration to JSON format
*/
apiRoutes.post('/config/rollback', async (req: Request, res: Response): Promise<void> => {
const { backupPath } = req.body;
if (!backupPath || typeof backupPath !== 'string') {
res.status(400).json({ error: 'Missing required field: backupPath' });
return;
}
const success = await rollback(backupPath);
res.json({ success });
});
/**
* PUT /api/secrets/:profile - Update profile secrets (write-only)
*/
apiRoutes.put('/secrets/:profile', (req: Request, res: Response): void => {
const { profile } = req.params;
const secrets = req.body;
if (!secrets || typeof secrets !== 'object') {
res.status(400).json({ error: 'Invalid secrets format' });
return;
}
try {
setProfileSecrets(profile, secrets as Record<string, string>);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
/**
* GET /api/secrets/:profile/exists - Check if secrets exist (no values returned)
*/
apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => {
const { profile } = req.params;
const secrets = getProfileSecrets(profile);
res.json({
exists: Object.keys(secrets).length > 0,
keys: Object.keys(secrets), // Only key names, not values
});
});
// ==================== Generic File API (Issue #73) ====================
/**
* Security: Validate file path is within allowed directories
* - ~/.ccs/ directory: read/write allowed
* - ~/.claude/settings.json: read-only
*/
function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string } {
const expandedPath = expandPath(filePath);
const normalizedPath = path.normalize(expandedPath);
const ccsDir = getCcsDir();
const claudeSettingsPath = expandPath('~/.claude/settings.json');
// Check if path is within ~/.ccs/
if (normalizedPath.startsWith(ccsDir)) {
// Block access to sensitive subdirectories
const relativePath = normalizedPath.slice(ccsDir.length);
if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) {
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
}
return { valid: true, readonly: false };
}
// Allow read-only access to ~/.claude/settings.json
if (normalizedPath === claudeSettingsPath) {
return { valid: true, readonly: true };
}
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
}
/**
* GET /api/file - Read a file with path validation
* Query params: path (required)
* Returns: { content: string, mtime: number, readonly: boolean, path: string }
*/
apiRoutes.get('/file', (req: Request, res: Response): void => {
const filePath = req.query.path as string;
if (!filePath) {
res.status(400).json({ error: 'Missing required query parameter: path' });
return;
}
const validation = validateFilePath(filePath);
if (!validation.valid) {
res.status(403).json({ error: validation.error });
return;
}
const expandedPath = expandPath(filePath);
if (!fs.existsSync(expandedPath)) {
res.status(404).json({ error: 'File not found' });
return;
}
try {
const stat = fs.statSync(expandedPath);
const content = fs.readFileSync(expandedPath, 'utf8');
res.json({
content,
mtime: stat.mtime.getTime(),
readonly: validation.readonly,
path: expandedPath,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/file - Write a file with conflict detection and backup
* Query params: path (required)
* Body: { content: string, expectedMtime?: number }
* Returns: { success: true, mtime: number, backupPath?: string }
*/
apiRoutes.put('/file', (req: Request, res: Response): void => {
const filePath = req.query.path as string;
const { content, expectedMtime } = req.body;
if (!filePath) {
res.status(400).json({ error: 'Missing required query parameter: path' });
return;
}
if (typeof content !== 'string') {
res.status(400).json({ error: 'Missing required field: content' });
return;
}
const validation = validateFilePath(filePath);
if (!validation.valid) {
res.status(403).json({ error: validation.error });
return;
}
if (validation.readonly) {
res.status(403).json({ error: 'File is read-only' });
return;
}
const expandedPath = expandPath(filePath);
const ccsDir = getCcsDir();
// Conflict detection (if file exists and expectedMtime provided)
if (fs.existsSync(expandedPath) && expectedMtime !== undefined) {
const stat = fs.statSync(expandedPath);
if (stat.mtime.getTime() !== expectedMtime) {
res.status(409).json({
error: 'File modified externally',
currentMtime: stat.mtime.getTime(),
});
return;
}
}
try {
// Create backup if file exists
let backupPath: string | undefined;
if (fs.existsSync(expandedPath)) {
const backupDir = path.join(ccsDir, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const filename = path.basename(expandedPath);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`);
fs.copyFileSync(expandedPath, backupPath);
}
// Ensure parent directory exists
const parentDir = path.dirname(expandedPath);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
// Write atomically
const tempPath = expandedPath + '.tmp';
fs.writeFileSync(tempPath, content);
fs.renameSync(tempPath, expandedPath);
const newStat = fs.statSync(expandedPath);
res.json({
success: true,
mtime: newStat.mtime.getTime(),
backupPath,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/files - List editable files in ~/.ccs/
* Returns: { files: Array<{ name: string, path: string, mtime: number }> }
*/
apiRoutes.get('/files', (_req: Request, res: Response): void => {
const ccsDir = getCcsDir();
if (!fs.existsSync(ccsDir)) {
res.json({ files: [] });
return;
}
try {
const entries = fs.readdirSync(ccsDir, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => {
const filePath = path.join(ccsDir, entry.name);
const stat = fs.statSync(filePath);
return {
name: entry.name,
path: `~/.ccs/${entry.name}`,
mtime: stat.mtime.getTime(),
};
})
.sort((a, b) => a.name.localeCompare(b.name));
res.json({ files });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
* Returns: CliproxyStats or error if proxy not running
*/
apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise<void> => {
try {
// Check if proxy is running first
const running = await isCliproxyRunning();
if (!running) {
res.status(503).json({
error: 'CLIProxyAPI not running',
message: 'Start a CLIProxy session (gemini, codex, agy) to collect stats',
});
return;
}
// Fetch stats from management API
const stats = await fetchCliproxyStats();
if (!stats) {
res.status(503).json({
error: 'Stats unavailable',
message: 'CLIProxyAPI is running but stats endpoint not responding',
});
return;
}
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/status - Check CLIProxyAPI running status
* Returns: { running: boolean }
*/
apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise<void> => {
try {
const running = await isCliproxyRunning();
res.json({ running });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/models - Get available models from CLIProxyAPI
* Returns: { models: CliproxyModel[], byCategory: Record<string, CliproxyModel[]>, totalCount: number }
*/
apiRoutes.get('/cliproxy/models', async (_req: Request, res: Response): Promise<void> => {
try {
// Check if proxy is running first
const running = await isCliproxyRunning();
if (!running) {
res.status(503).json({
error: 'CLIProxyAPI not running',
message: 'Start a CLIProxy session (gemini, codex, agy) to fetch available models',
});
return;
}
// Fetch models from /v1/models endpoint
const modelsResponse = await fetchCliproxyModels();
if (!modelsResponse) {
res.status(503).json({
error: 'Models unavailable',
message: 'CLIProxyAPI is running but /v1/models endpoint not responding',
});
return;
}
res.json(modelsResponse);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// ============================================
// OpenAI Compatibility Layer Routes
// ============================================
/**
* GET /api/cliproxy/openai-compat - List all OpenAI-compatible providers
*/
apiRoutes.get('/cliproxy/openai-compat', (_req: Request, res: Response): void => {
try {
const providers = listOpenAICompatProviders();
// Mask API keys for security
const masked = providers.map((p) => ({
...p,
apiKey: p.apiKey ? `...${p.apiKey.slice(-4)}` : '',
}));
res.json({ providers: masked });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/openai-compat/templates - Get pre-configured provider templates
*/
apiRoutes.get('/cliproxy/openai-compat/templates', (_req: Request, res: Response): void => {
res.json({
templates: [
{ ...OPENROUTER_TEMPLATE, description: 'OpenRouter - Access multiple AI models' },
{ ...TOGETHER_TEMPLATE, description: 'Together AI - Open source models' },
],
});
});
/**
* GET /api/cliproxy/openai-compat/:name - Get a specific provider
*/
apiRoutes.get('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => {
try {
const provider = getOpenAICompatProvider(req.params.name);
if (!provider) {
res.status(404).json({ error: `Provider '${req.params.name}' not found` });
return;
}
// Mask API key
res.json({
...provider,
apiKey: provider.apiKey ? `...${provider.apiKey.slice(-4)}` : '',
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/cliproxy/openai-compat - Add a new provider
* Body: { name, baseUrl, apiKey, models: [{ name, alias }] }
*/
apiRoutes.post('/cliproxy/openai-compat', (req: Request, res: Response): void => {
try {
const { name, baseUrl, apiKey, models } = req.body;
// Validation
if (!name || typeof name !== 'string') {
res.status(400).json({ error: 'name is required' });
return;
}
if (!baseUrl || typeof baseUrl !== 'string') {
res.status(400).json({ error: 'baseUrl is required' });
return;
}
if (!apiKey || typeof apiKey !== 'string') {
res.status(400).json({ error: 'apiKey is required' });
return;
}
addOpenAICompatProvider({
name,
baseUrl,
apiKey,
models: models || [],
});
res.status(201).json({ success: true, name });
} catch (error) {
const message = (error as Error).message;
if (message.includes('already exists')) {
res.status(409).json({ error: message });
} else {
res.status(500).json({ error: message });
}
}
});
/**
* PUT /api/cliproxy/openai-compat/:name - Update a provider
* Body: { baseUrl?, apiKey?, models?, name? (for rename) }
*/
apiRoutes.put('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => {
try {
const { baseUrl, apiKey, models, name: newName } = req.body;
updateOpenAICompatProvider(req.params.name, {
baseUrl,
apiKey,
models,
name: newName,
});
res.json({ success: true });
} catch (error) {
const message = (error as Error).message;
if (message.includes('not found')) {
res.status(404).json({ error: message });
} else {
res.status(500).json({ error: message });
}
}
});
/**
* DELETE /api/cliproxy/openai-compat/:name - Remove a provider
*/
apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => {
try {
const removed = removeOpenAICompatProvider(req.params.name);
if (!removed) {
res.status(404).json({ error: `Provider '${req.params.name}' not found` });
return;
}
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// ==================== WebSearch Configuration ====================
/**
* GET /api/websearch - Get WebSearch configuration
* Returns: WebSearchConfig with enabled, provider, fallback
*/
apiRoutes.get('/websearch', (_req: Request, res: Response): void => {
try {
const config = getWebSearchConfig();
res.json(config);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/websearch - Update WebSearch configuration
* Body: WebSearchConfig fields (enabled, providers)
* Dashboard is the source of truth for provider selection.
*/
apiRoutes.put('/websearch', (req: Request, res: Response): void => {
const { enabled, providers } = req.body as Partial<WebSearchConfig>;
// Validate enabled
if (enabled !== undefined && typeof enabled !== 'boolean') {
res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' });
return;
}
// Validate providers if specified
if (providers !== undefined && typeof providers !== 'object') {
res.status(400).json({ error: 'Invalid value for providers. Must be an object.' });
return;
}
try {
// Load existing config and update websearch section
const existingConfig = loadUnifiedConfig();
if (!existingConfig) {
res.status(500).json({ error: 'Failed to load config' });
return;
}
// Merge updates - simple structure (Gemini CLI only for now)
existingConfig.websearch = {
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
providers: providers
? {
gemini: {
enabled:
providers.gemini?.enabled ??
existingConfig.websearch?.providers?.gemini?.enabled ??
true,
timeout:
providers.gemini?.timeout ??
existingConfig.websearch?.providers?.gemini?.timeout ??
55,
},
}
: existingConfig.websearch?.providers,
};
saveUnifiedConfig(existingConfig);
res.json({
success: true,
websearch: existingConfig.websearch,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/websearch/status - Get WebSearch status
* Returns: { geminiCli, grokCli, readiness }
*/
apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
try {
const geminiCli = getGeminiCliStatus();
const grokCli = getGrokCliStatus();
const readiness = getWebSearchReadiness();
res.json({
geminiCli: {
installed: geminiCli.installed,
path: geminiCli.path,
version: geminiCli.version,
},
grokCli: {
installed: grokCli.installed,
path: grokCli.path,
version: grokCli.version,
},
readiness: {
status: readiness.readiness,
message: readiness.message,
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});