Merge pull request #174 from kaitranntt/kai/fix/add-cliproxy-account

fix(api): resolve CLIProxy account management errors
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-21 18:25:20 -05:00
commit 8b2fceb743
10 files changed
+636 -350

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
/**
* Account Routes - CRUD operations for Claude accounts (profiles.json)
*
* Separated from profile-routes.ts to avoid dual-mounting conflicts.
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
const router = Router();
/**
* GET /api/accounts - List accounts from profiles.json
*/
router.get('/', (_req: Request, res: Response): void => {
try {
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]) => {
const metadata = meta as Record<string, unknown>;
return {
name,
...metadata,
};
});
res.json({ accounts, default: data.default || null });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/accounts/default - Set default account
*/
router.post('/default', (req: Request, res: Response): void => {
try {
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 });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
+86 -58
View File
@@ -32,57 +32,61 @@ const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'i
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
*/
router.get('/', async (_req: Request, res: Response) => {
// Initialize accounts from existing tokens on first request
initializeAccounts();
router.get('/', async (_req: Request, res: Response): Promise<void> => {
try {
// Initialize accounts from existing tokens on first request
initializeAccounts();
// Fetch CLIProxyAPI usage stats to determine active providers
const stats = await fetchCliproxyStats();
// Fetch CLIProxyAPI usage stats to determine active providers
const stats = await fetchCliproxyStats();
// Map CLIProxyAPI provider names to our internal provider names
const statsProviderMap: Record<string, CLIProxyProvider> = {
gemini: 'gemini',
antigravity: 'agy',
codex: 'codex',
qwen: 'qwen',
iflow: 'iflow',
};
// Map CLIProxyAPI provider names to our internal provider names
const statsProviderMap: Record<string, CLIProxyProvider> = {
gemini: 'gemini',
antigravity: 'agy',
codex: 'codex',
qwen: 'qwen',
iflow: 'iflow',
};
// Update lastUsedAt for providers with recent activity
if (stats?.requestsByProvider) {
for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) {
if (requestCount > 0) {
const provider = statsProviderMap[statsProvider.toLowerCase()];
if (provider) {
// Touch the default account for this provider (or all accounts)
const accounts = getProviderAccounts(provider);
for (const account of accounts) {
// Only touch if this is the default account (most likely being used)
if (account.isDefault) {
touchAccount(provider, account.id);
// Update lastUsedAt for providers with recent activity
if (stats?.requestsByProvider) {
for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) {
if (requestCount > 0) {
const provider = statsProviderMap[statsProvider.toLowerCase()];
if (provider) {
// Touch the default account for this provider (or all accounts)
const accounts = getProviderAccounts(provider);
for (const account of accounts) {
// Only touch if this is the default account (most likely being used)
if (account.isDefault) {
touchAccount(provider, account.id);
}
}
}
}
}
}
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 });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
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 ====================
@@ -91,11 +95,16 @@ router.get('/', async (_req: Request, res: Response) => {
* GET /api/cliproxy/accounts - Get all accounts across all providers
*/
router.get('/accounts', (_req: Request, res: Response) => {
// Initialize accounts from existing tokens
initializeAccounts();
try {
// Initialize accounts from existing tokens
initializeAccounts();
const accounts = getAllAccountsSummary();
res.json({ accounts });
const accounts = getAllAccountsSummary();
res.json({ accounts });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list accounts';
res.status(500).json({ error: message });
}
});
/**
@@ -110,8 +119,13 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => {
return;
}
const accounts = getProviderAccounts(provider as CLIProxyProvider);
res.json({ provider, accounts });
try {
const accounts = getProviderAccounts(provider as CLIProxyProvider);
res.json({ provider, accounts });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get provider accounts';
res.status(500).json({ error: message });
}
});
/**
@@ -132,12 +146,19 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void =
return;
}
const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId);
try {
const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId);
if (success) {
res.json({ provider, defaultAccount: accountId });
} else {
res.status(404).json({ error: 'Account not found' });
if (success) {
res.json({ provider, defaultAccount: accountId });
} else {
res
.status(404)
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to set default account';
res.status(500).json({ error: message });
}
});
@@ -153,12 +174,19 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v
return;
}
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
try {
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
if (success) {
res.json({ provider, accountId, deleted: true });
} else {
res.status(404).json({ error: 'Account not found' });
if (success) {
res.json({ provider, accountId, deleted: true });
} else {
res
.status(404)
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to remove account';
res.status(500).json({ error: message });
}
});
+186 -5
View File
@@ -3,6 +3,7 @@
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import {
fetchCliproxyStats,
@@ -11,7 +12,11 @@ import {
fetchCliproxyErrorLogs,
fetchCliproxyErrorLogContent,
} from '../../cliproxy/stats-fetcher';
import { getCliproxyWritablePath } from '../../cliproxy/config-generator';
import {
getCliproxyWritablePath,
getConfigPath,
getAuthDir,
} from '../../cliproxy/config-generator';
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
import { ensureCliproxyService } from '../../cliproxy/service-manager';
import { checkCliproxyUpdate } from '../../cliproxy/binary-manager';
@@ -19,10 +24,9 @@ import { checkCliproxyUpdate } from '../../cliproxy/binary-manager';
const router = Router();
/**
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
* Returns: CliproxyStats or error if proxy not running
* Shared handler for stats/usage endpoint
*/
router.get('/stats', async (_req: Request, res: Response): Promise<void> => {
const handleStatsRequest = async (_req: Request, res: Response): Promise<void> => {
try {
// Check if proxy is running first
const running = await isCliproxyRunning();
@@ -48,7 +52,18 @@ router.get('/stats', async (_req: Request, res: Response): Promise<void> => {
} 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
*/
router.get('/stats', handleStatsRequest);
/**
* GET /api/cliproxy/usage - Alias for /stats (frontend compatibility)
*/
router.get('/usage', handleStatsRequest);
/**
* GET /api/cliproxy/status - Check CLIProxyAPI running status
@@ -249,4 +264,170 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise<voi
}
});
// ==================== Config File ====================
/**
* GET /api/cliproxy/config.yaml - Get CLIProxy YAML config content
* Returns: plain text YAML content
*/
router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> => {
try {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
res.status(404).json({ error: 'Config file not found' });
return;
}
const content = fs.readFileSync(configPath, 'utf8');
res.type('text/yaml').send(content);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/cliproxy/config.yaml - Save CLIProxy YAML config content
* Body: { content: string }
* Returns: { success: true, path: string }
*/
router.put('/config.yaml', async (req: Request, res: Response): Promise<void> => {
try {
const { content } = req.body;
if (typeof content !== 'string') {
res.status(400).json({ error: 'Missing required field: content' });
return;
}
const configPath = getConfigPath();
// Ensure parent directory exists
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
// Write atomically
const tempPath = configPath + '.tmp';
fs.writeFileSync(tempPath, content);
fs.renameSync(tempPath, configPath);
res.json({ success: true, path: configPath });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// ==================== Auth Files ====================
/**
* GET /api/cliproxy/auth-files - List auth files in auth directory
* Returns: { files: Array<{ name, size, mtime }> }
*/
router.get('/auth-files', async (_req: Request, res: Response): Promise<void> => {
try {
const authDir = getAuthDir();
if (!fs.existsSync(authDir)) {
res.json({ files: [] });
return;
}
const entries = fs.readdirSync(authDir, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => {
const filePath = path.join(authDir, entry.name);
const stat = fs.statSync(filePath);
return {
name: entry.name,
size: stat.size,
mtime: stat.mtime.getTime(),
};
});
res.json({ files, directory: authDir });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/auth-files/download - Download auth file content
* Query: ?name=filename
* Returns: file content as octet-stream
*/
router.get('/auth-files/download', async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.query;
if (!name || typeof name !== 'string') {
res.status(400).json({ error: 'Missing required query parameter: name' });
return;
}
// Validate filename - prevent path traversal
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
res.status(400).json({ error: 'Invalid filename' });
return;
}
const authDir = getAuthDir();
const filePath = path.join(authDir, name);
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: 'Auth file not found' });
return;
}
const content = fs.readFileSync(filePath);
res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
res.type('application/octet-stream').send(content);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// ==================== Model Updates ====================
/**
* PUT /api/cliproxy/models/:provider - Update model for a provider
* Body: { model: string }
* Returns: { success: true, provider, model }
*/
router.put('/models/:provider', async (req: Request, res: Response): Promise<void> => {
try {
const { provider } = req.params;
const { model } = req.body;
if (!model || typeof model !== 'string') {
res.status(400).json({ error: 'Missing required field: model' });
return;
}
// Get the settings file for this provider
const ccsDir = getCliproxyWritablePath();
const settingsPath = path.join(ccsDir, `${provider}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: `Settings file not found for provider: ${provider}` });
return;
}
// Read and update settings
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
settings.env = settings.env || {};
settings.env.ANTHROPIC_MODEL = model;
// Write atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
res.json({ success: true, provider, model });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
+29 -17
View File
@@ -24,12 +24,16 @@ const router = Router();
/**
* GET /api/config/format - Return current config format and migration status
*/
router.get('/format', (_req: Request, res: Response) => {
res.json({
format: getConfigFormat(),
migrationNeeded: needsMigration(),
backups: getBackupDirectories(),
});
router.get('/format', (_req: Request, res: Response): void => {
try {
res.json({
format: getConfigFormat(),
migrationNeeded: needsMigration(),
backups: getBackupDirectories(),
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
@@ -89,25 +93,33 @@ router.put('/', (req: Request, res: Response): void => {
/**
* POST /api/config/migrate - Trigger migration from JSON to YAML
*/
router.post('/migrate', async (req: Request, res: Response) => {
const dryRun = req.query.dryRun === 'true';
const result = await migrate(dryRun);
res.json(result);
router.post('/migrate', async (req: Request, res: Response): Promise<void> => {
try {
const dryRun = req.query.dryRun === 'true';
const result = await migrate(dryRun);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/config/rollback - Rollback migration to JSON format
*/
router.post('/rollback', async (req: Request, res: Response): Promise<void> => {
const { backupPath } = req.body;
try {
const { backupPath } = req.body;
if (!backupPath || typeof backupPath !== 'string') {
res.status(400).json({ error: 'Missing required field: backupPath' });
return;
if (!backupPath || typeof backupPath !== 'string') {
res.status(400).json({ error: 'Missing required field: backupPath' });
return;
}
const success = await rollback(backupPath);
res.json({ success });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
const success = await rollback(backupPath);
res.json({ success });
});
export default router;
+17 -9
View File
@@ -10,22 +10,30 @@ const router = Router();
/**
* GET /api/health - Run health checks
*/
router.get('/', async (_req: Request, res: Response) => {
const report = await runHealthChecks();
res.json(report);
router.get('/', async (_req: Request, res: Response): Promise<void> => {
try {
const report = await runHealthChecks();
res.json(report);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/health/fix/:checkId - Fix a health issue
*/
router.post('/fix/:checkId', (req: Request, res: Response): void => {
const { checkId } = req.params;
const result = fixHealthIssue(checkId);
try {
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 });
if (result.success) {
res.json({ success: true, message: result.message });
} else {
res.status(400).json({ success: false, message: result.message });
}
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
+2 -1
View File
@@ -9,6 +9,7 @@ import { Router } from 'express';
// Import domain routers
import profileRoutes from './profile-routes';
import accountRoutes from './account-routes';
import configRoutes from './config-routes';
import healthRoutes from './health-routes';
import providerRoutes from './provider-routes';
@@ -28,7 +29,7 @@ export const apiRoutes = Router();
// Profile CRUD, settings management, presets, accounts
apiRoutes.use('/profiles', profileRoutes);
apiRoutes.use('/settings', settingsRoutes);
apiRoutes.use('/accounts', profileRoutes);
apiRoutes.use('/accounts', accountRoutes);
// ==================== Unified Config ====================
// Config format, migration
+15 -61
View File
@@ -1,13 +1,11 @@
/**
* Profile Routes - CRUD operations for user profiles and accounts
* Profile Routes - CRUD operations for user profiles
*
* Uses unified config (config.yaml) when available, falls back to legacy (config.json).
* Note: Account routes have been moved to account-routes.ts
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer';
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
@@ -20,15 +18,19 @@ const router = Router();
/**
* GET /api/profiles - List all profiles
*/
router.get('/', (_req: Request, res: Response) => {
const result = listApiProfiles();
// Map isConfigured -> configured for UI compatibility
const profiles = result.profiles.map((p) => ({
name: p.name,
settingsPath: p.settingsPath,
configured: p.isConfigured,
}));
res.json({ profiles });
router.get('/', (_req: Request, res: Response): void => {
try {
const result = listApiProfiles();
// Map isConfigured -> configured for UI compatibility
const profiles = result.profiles.map((p) => ({
name: p.name,
settingsPath: p.settingsPath,
configured: p.isConfigured,
}));
res.json({ profiles });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
@@ -117,52 +119,4 @@ router.delete('/:name', (req: Request, res: Response): void => {
res.json({ name, deleted: true });
});
// ==================== Accounts ====================
/**
* GET /api/accounts - List accounts from profiles.json
*/
router.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]) => {
const metadata = meta as Record<string, unknown>;
return {
name,
...metadata,
};
});
res.json({ accounts, default: data.default || null });
});
/**
* POST /api/accounts/default - Set default account
*/
router.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 });
});
export default router;
+160 -136
View File
@@ -32,103 +32,115 @@ function maskApiKeys(settings: Settings): Settings {
* GET /api/settings/:profile - Get settings with masked API keys
*/
router.get('/:profile', (req: Request, res: Response): void => {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
try {
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;
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath);
const masked = maskApiKeys(settings);
res.json({
profile,
settings: masked,
mtime: stat.mtime.getTime(),
path: settingsPath,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath);
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)
*/
router.get('/:profile/raw', (req: Request, res: Response): void => {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
try {
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;
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,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
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
*/
router.put('/: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`);
try {
const { profile } = req.params;
const { settings, expectedMtime } = req.body;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
const fileExists = fs.existsSync(settingsPath);
const fileExists = fs.existsSync(settingsPath);
// Only check conflict if file exists and expectedMtime was provided
if (fileExists && expectedMtime) {
const stat = fs.statSync(settingsPath);
if (stat.mtime.getTime() !== expectedMtime) {
res.status(409).json({
error: 'File modified externally',
currentMtime: stat.mtime.getTime(),
});
return;
// Only check conflict if file exists and expectedMtime was provided
if (fileExists && expectedMtime) {
const stat = fs.statSync(settingsPath);
if (stat.mtime.getTime() !== expectedMtime) {
res.status(409).json({
error: 'File modified externally',
currentMtime: stat.mtime.getTime(),
});
return;
}
}
}
// Create backup only if file exists
let backupPath: string | undefined;
if (fileExists) {
const backupDir = path.join(ccsDir, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
// Create backup only if file exists
let backupPath: string | undefined;
if (fileExists) {
const backupDir = path.join(ccsDir, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
fs.copyFileSync(settingsPath, backupPath);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
fs.copyFileSync(settingsPath, backupPath);
// Ensure directory exists for new files
if (!fileExists) {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
}
// 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,
created: !fileExists,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
// Ensure directory exists for new files
if (!fileExists) {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
}
// 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,
created: !fileExists,
});
});
// ==================== Presets ====================
@@ -137,85 +149,97 @@ router.put('/:profile', (req: Request, res: Response): void => {
* GET /api/settings/:profile/presets - Get saved presets for a provider
*/
router.get('/:profile/presets', (req: Request, res: Response): void => {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
try {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.json({ presets: [] });
return;
if (!fs.existsSync(settingsPath)) {
res.json({ presets: [] });
return;
}
const settings = loadSettings(settingsPath);
res.json({ presets: settings.presets || [] });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
const settings = loadSettings(settingsPath);
res.json({ presets: settings.presets || [] });
});
/**
* POST /api/settings/:profile/presets - Create a new preset
*/
router.post('/:profile/presets', (req: Request, res: Response): void => {
const { profile } = req.params;
const { name, default: defaultModel, opus, sonnet, haiku } = req.body;
try {
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;
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 });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
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
*/
router.delete('/:profile/presets/:name', (req: Request, res: Response): void => {
const { profile, name } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
try {
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;
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 });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
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 });
});
export default router;
+67 -59
View File
@@ -78,82 +78,90 @@ router.post('/', (req: Request, res: Response): void => {
* PUT /api/cliproxy/:name - Update cliproxy variant
*/
router.put('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const { provider, account, model } = req.body;
try {
const { name } = req.params;
const { provider, account, model } = req.body;
const config = readConfigSafe();
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
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
return;
}
}
// 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;
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
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
// 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,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
writeConfig(config);
res.json({
name,
provider: variant.provider,
account: variant.account || 'default',
settings: variant.settings,
updated: true,
});
});
/**
* DELETE /api/cliproxy/:name - Delete cliproxy variant
*/
router.delete('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
try {
const { name } = req.params;
const config = readConfigSafe();
const config = readConfigSafe();
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
return;
}
// Never delete settings files for reserved provider names (safety guard)
if (!isReservedName(name)) {
// Only delete settings file for non-reserved variant names
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
return;
}
// Never delete settings files for reserved provider names (safety guard)
if (!isReservedName(name)) {
// Only delete settings file for non-reserved variant names
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 });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
delete config.cliproxy[name];
writeConfig(config);
res.json({ name, deleted: true });
});
export default router;
+6 -4
View File
@@ -307,16 +307,18 @@ export const api = {
// Multi-account management
accounts: {
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/accounts'),
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/auth/accounts'),
listByProvider: (provider: string) =>
request<{ provider: string; accounts: OAuthAccount[] }>(`/cliproxy/accounts/${provider}`),
request<{ provider: string; accounts: OAuthAccount[] }>(
`/cliproxy/auth/accounts/${provider}`
),
setDefault: (provider: string, accountId: string) =>
request(`/cliproxy/accounts/${provider}/default`, {
request(`/cliproxy/auth/accounts/${provider}/default`, {
method: 'POST',
body: JSON.stringify({ accountId }),
}),
remove: (provider: string, accountId: string) =>
request(`/cliproxy/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
},
// OAuth flow
auth: {