mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 16:16:46 +00:00
refactor(web-server): modularize routes into dedicated files
- extract 14 route modules from monolithic routes.ts (2198 → 49 lines) - add route-helpers.ts for shared utilities - add index.ts barrel export for clean imports - health, profile, config, settings, provider, copilot routes - variant, proxy, cliproxy-auth, cliproxy-stats, websearch, misc routes Phase 1: Routes Modularization
This commit is contained in:
+6
-2192
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* CLIProxy Auth Routes - Authentication and account management for CLIProxy providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import {
|
||||||
|
getAllAuthStatus,
|
||||||
|
getOAuthConfig,
|
||||||
|
initializeAccounts,
|
||||||
|
triggerOAuth,
|
||||||
|
} from '../../cliproxy/auth-handler';
|
||||||
|
import {
|
||||||
|
submitProjectSelection,
|
||||||
|
getPendingSelection,
|
||||||
|
} from '../../cliproxy/project-selection-handler';
|
||||||
|
import { fetchCliproxyStats } from '../../cliproxy/stats-fetcher';
|
||||||
|
import {
|
||||||
|
getAllAccountsSummary,
|
||||||
|
getProviderAccounts,
|
||||||
|
setDefaultAccount as setDefaultAccountFn,
|
||||||
|
removeAccount as removeAccountFn,
|
||||||
|
touchAccount,
|
||||||
|
} from '../../cliproxy/account-manager';
|
||||||
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Valid providers list
|
||||||
|
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Account Management ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/accounts - Get all accounts across all providers
|
||||||
|
*/
|
||||||
|
router.get('/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
|
||||||
|
*/
|
||||||
|
router.get('/accounts/:provider', (req: Request, res: Response): void => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
|
||||||
|
// Validate provider
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
router.post('/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
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): void => {
|
||||||
|
const { provider, accountId } = req.params;
|
||||||
|
|
||||||
|
// Validate provider
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
router.post('/:provider/start', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const { nickname } = req.body;
|
||||||
|
|
||||||
|
// Validate provider
|
||||||
|
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,
|
||||||
|
fromUI: true, // Enable project selection prompt in UI
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/auth/project-selection/:sessionId - Get pending project selection prompt
|
||||||
|
* Returns project list for user to select from during OAuth flow
|
||||||
|
*/
|
||||||
|
router.get('/project-selection/:sessionId', (req: Request, res: Response): void => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
const pending = getPendingSelection(sessionId);
|
||||||
|
if (pending) {
|
||||||
|
res.json(pending);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'No pending project selection for this session' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cliproxy/auth/project-selection/:sessionId - Submit project selection
|
||||||
|
* Submits user's project choice during OAuth flow
|
||||||
|
*/
|
||||||
|
router.post('/project-selection/:sessionId', (req: Request, res: Response): void => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const { selectedId } = req.body;
|
||||||
|
|
||||||
|
if (!selectedId && selectedId !== '') {
|
||||||
|
res.status(400).json({ error: 'selectedId is required (use empty string for default)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = submitProjectSelection({ sessionId, selectedId });
|
||||||
|
if (success) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'No pending project selection for this session' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* CLIProxy Stats Routes - Stats, status, models, error logs for CLIProxyAPI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
fetchCliproxyStats,
|
||||||
|
fetchCliproxyModels,
|
||||||
|
isCliproxyRunning,
|
||||||
|
fetchCliproxyErrorLogs,
|
||||||
|
fetchCliproxyErrorLogContent,
|
||||||
|
} from '../../cliproxy/stats-fetcher';
|
||||||
|
import { getCliproxyWritablePath } 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';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
|
||||||
|
* Returns: CliproxyStats or error if proxy not running
|
||||||
|
*/
|
||||||
|
router.get('/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 }
|
||||||
|
*/
|
||||||
|
router.get('/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/proxy-status - Get detailed proxy process status
|
||||||
|
* Returns: { running, port?, pid?, sessionCount?, startedAt? }
|
||||||
|
* Combines session tracker data with actual port check for accuracy
|
||||||
|
*/
|
||||||
|
router.get('/proxy-status', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// First check session tracker for detailed info
|
||||||
|
const sessionStatus = getProxyProcessStatus();
|
||||||
|
|
||||||
|
// If session tracker says running, trust it
|
||||||
|
if (sessionStatus.running) {
|
||||||
|
res.json(sessionStatus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session tracker says not running, but proxy might be running without session tracking
|
||||||
|
// (e.g., started before session persistence was implemented)
|
||||||
|
const actuallyRunning = await isCliproxyRunning();
|
||||||
|
|
||||||
|
if (actuallyRunning) {
|
||||||
|
// Proxy running but no session lock - legacy/untracked instance
|
||||||
|
res.json({
|
||||||
|
running: true,
|
||||||
|
port: 8317, // Default port
|
||||||
|
sessionCount: 0, // Unknown sessions
|
||||||
|
// No pid/startedAt since we don't have session lock
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json(sessionStatus);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cliproxy/proxy-start - Start the CLIProxy service
|
||||||
|
* Returns: { started, alreadyRunning, port, error? }
|
||||||
|
* Starts proxy in background if not already running
|
||||||
|
*/
|
||||||
|
router.post('/proxy-start', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result = await ensureCliproxyService();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cliproxy/proxy-stop - Stop the CLIProxy service
|
||||||
|
* Returns: { stopped, pid?, sessionCount?, error? }
|
||||||
|
*/
|
||||||
|
router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result = await stopProxy();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/update-check - Check for CLIProxyAPI binary updates
|
||||||
|
* Returns: { hasUpdate, currentVersion, latestVersion, fromCache }
|
||||||
|
*/
|
||||||
|
router.get('/update-check', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result = await checkCliproxyUpdate();
|
||||||
|
res.json(result);
|
||||||
|
} 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 }
|
||||||
|
*/
|
||||||
|
router.get('/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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Error Logs ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/error-logs - Get list of error log files
|
||||||
|
* Returns: { files: CliproxyErrorLog[] } or error if proxy not running
|
||||||
|
*/
|
||||||
|
router.get('/error-logs', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const running = await isCliproxyRunning();
|
||||||
|
if (!running) {
|
||||||
|
res.status(503).json({
|
||||||
|
error: 'CLIProxyAPI not running',
|
||||||
|
message: 'Start a CLIProxy session to view error logs',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await fetchCliproxyErrorLogs();
|
||||||
|
if (files === null) {
|
||||||
|
res.status(503).json({
|
||||||
|
error: 'Error logs unavailable',
|
||||||
|
message: 'CLIProxyAPI is running but error logs endpoint not responding',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject absolute paths into each file entry
|
||||||
|
const logsDir = path.join(getCliproxyWritablePath(), 'logs');
|
||||||
|
const filesWithPaths = files.map((file) => ({
|
||||||
|
...file,
|
||||||
|
absolutePath: path.join(logsDir, file.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ files: filesWithPaths });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/error-logs/:name - Get content of a specific error log
|
||||||
|
* Returns: plain text log content
|
||||||
|
*/
|
||||||
|
router.get('/error-logs/:name', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
// Validate filename format and prevent path traversal
|
||||||
|
if (
|
||||||
|
!name ||
|
||||||
|
!name.startsWith('error-') ||
|
||||||
|
!name.endsWith('.log') ||
|
||||||
|
name.includes('..') ||
|
||||||
|
name.includes('/') ||
|
||||||
|
name.includes('\\')
|
||||||
|
) {
|
||||||
|
res.status(400).json({ error: 'Invalid error log filename' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const running = await isCliproxyRunning();
|
||||||
|
if (!running) {
|
||||||
|
res.status(503).json({ error: 'CLIProxyAPI not running' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fetchCliproxyErrorLogContent(name);
|
||||||
|
if (content === null) {
|
||||||
|
res.status(404).json({ error: 'Error log not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.type('text/plain').send(content);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Config Routes - Unified config management and migration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
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 { isUnifiedConfig } from '../../config/unified-config-types';
|
||||||
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/config - Return unified config (excludes secrets)
|
||||||
|
*/
|
||||||
|
router.get('/', (_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
|
||||||
|
*/
|
||||||
|
router.get('/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
|
||||||
|
*/
|
||||||
|
router.put('/', (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
|
||||||
|
*/
|
||||||
|
router.post('/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
|
||||||
|
*/
|
||||||
|
router.post('/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)
|
||||||
|
*/
|
||||||
|
router.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)
|
||||||
|
*/
|
||||||
|
router.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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Copilot Routes - GitHub Copilot integration via copilot-api proxy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import {
|
||||||
|
checkAuthStatus as checkCopilotAuth,
|
||||||
|
startAuthFlow as startCopilotAuth,
|
||||||
|
getCopilotStatus,
|
||||||
|
startDaemon as startCopilotDaemon,
|
||||||
|
stopDaemon as stopCopilotDaemon,
|
||||||
|
getAvailableModels as getCopilotModels,
|
||||||
|
isCopilotApiInstalled,
|
||||||
|
ensureCopilotApi,
|
||||||
|
installCopilotApiVersion,
|
||||||
|
getCopilotApiInfo,
|
||||||
|
getInstalledVersion as getCopilotInstalledVersion,
|
||||||
|
} from '../../copilot';
|
||||||
|
import { DEFAULT_COPILOT_CONFIG } from '../../config/unified-config-types';
|
||||||
|
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
|
import copilotSettingsRoutes from './copilot-settings-routes';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Mount settings sub-routes
|
||||||
|
router.use('/settings', copilotSettingsRoutes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/copilot/status - Get Copilot status (auth + daemon + install info)
|
||||||
|
*/
|
||||||
|
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
|
||||||
|
const status = await getCopilotStatus(copilotConfig);
|
||||||
|
const installed = isCopilotApiInstalled();
|
||||||
|
const version = getCopilotInstalledVersion();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
enabled: copilotConfig.enabled,
|
||||||
|
installed,
|
||||||
|
version,
|
||||||
|
authenticated: status.auth.authenticated,
|
||||||
|
daemon_running: status.daemon.running,
|
||||||
|
port: copilotConfig.port,
|
||||||
|
model: copilotConfig.model,
|
||||||
|
account_type: copilotConfig.account_type,
|
||||||
|
auto_start: copilotConfig.auto_start,
|
||||||
|
rate_limit: copilotConfig.rate_limit,
|
||||||
|
wait_on_limit: copilotConfig.wait_on_limit,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/copilot/config - Get Copilot configuration
|
||||||
|
*/
|
||||||
|
router.get('/config', (_req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
|
||||||
|
res.json(copilotConfig);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/copilot/config - Update Copilot configuration
|
||||||
|
*/
|
||||||
|
router.put('/config', (req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const updates = req.body;
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
|
||||||
|
// Merge updates with existing config
|
||||||
|
config.copilot = {
|
||||||
|
enabled: updates.enabled ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
|
||||||
|
auto_start:
|
||||||
|
updates.auto_start ?? config.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start,
|
||||||
|
port: updates.port ?? config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
|
||||||
|
account_type:
|
||||||
|
updates.account_type ?? config.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type,
|
||||||
|
rate_limit:
|
||||||
|
updates.rate_limit !== undefined
|
||||||
|
? updates.rate_limit
|
||||||
|
: (config.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit),
|
||||||
|
wait_on_limit:
|
||||||
|
updates.wait_on_limit ??
|
||||||
|
config.copilot?.wait_on_limit ??
|
||||||
|
DEFAULT_COPILOT_CONFIG.wait_on_limit,
|
||||||
|
model: updates.model ?? config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
|
||||||
|
opus_model:
|
||||||
|
updates.opus_model !== undefined ? updates.opus_model : config.copilot?.opus_model,
|
||||||
|
sonnet_model:
|
||||||
|
updates.sonnet_model !== undefined ? updates.sonnet_model : config.copilot?.sonnet_model,
|
||||||
|
haiku_model:
|
||||||
|
updates.haiku_model !== undefined ? updates.haiku_model : config.copilot?.haiku_model,
|
||||||
|
};
|
||||||
|
|
||||||
|
saveUnifiedConfig(config);
|
||||||
|
res.json({ success: true, copilot: config.copilot });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/copilot/auth/start - Start GitHub OAuth flow
|
||||||
|
*/
|
||||||
|
router.post('/auth/start', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result = await startCopilotAuth();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/copilot/auth/status - Get auth status only
|
||||||
|
*/
|
||||||
|
router.get('/auth/status', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const status = await checkCopilotAuth();
|
||||||
|
res.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/copilot/models - Get available models
|
||||||
|
*/
|
||||||
|
router.get('/models', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port;
|
||||||
|
const currentModel = config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model;
|
||||||
|
const models = await getCopilotModels(port);
|
||||||
|
|
||||||
|
const modelsWithCurrent = models.map((m) => ({
|
||||||
|
...m,
|
||||||
|
isCurrent: m.id === currentModel,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ models: modelsWithCurrent, current: currentModel });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/copilot/daemon/start - Start copilot-api daemon
|
||||||
|
*/
|
||||||
|
router.post('/daemon/start', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
|
||||||
|
const result = await startCopilotDaemon(copilotConfig);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/copilot/daemon/stop - Stop copilot-api daemon
|
||||||
|
*/
|
||||||
|
router.post('/daemon/stop', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result = await stopCopilotDaemon();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/copilot/install - Install copilot-api
|
||||||
|
*/
|
||||||
|
router.post('/install', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { version } = req.body || {};
|
||||||
|
|
||||||
|
if (version) {
|
||||||
|
await installCopilotApiVersion(version);
|
||||||
|
} else {
|
||||||
|
await ensureCopilotApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = getCopilotApiInfo();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
installed: info.installed,
|
||||||
|
version: info.version,
|
||||||
|
path: info.path,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/copilot/info - Get copilot-api installation info
|
||||||
|
*/
|
||||||
|
router.get('/info', (_req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const info = getCopilotApiInfo();
|
||||||
|
res.json(info);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Copilot Settings Routes - Settings editor and raw settings for GitHub Copilot
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCcsDir } from '../../utils/config-manager';
|
||||||
|
import { DEFAULT_COPILOT_CONFIG } from '../../config/unified-config-types';
|
||||||
|
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/copilot/settings/raw - Get raw copilot.settings.json
|
||||||
|
* Returns the raw JSON content for editing in the code editor
|
||||||
|
*/
|
||||||
|
router.get('/raw', (_req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json');
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
|
||||||
|
|
||||||
|
// Default model for all tiers
|
||||||
|
const defaultModel = copilotConfig.model;
|
||||||
|
|
||||||
|
// If file doesn't exist, return default structure with all model mappings
|
||||||
|
if (!fs.existsSync(settingsPath)) {
|
||||||
|
// Create settings structure matching CLIProxy pattern
|
||||||
|
// Use 127.0.0.1 instead of localhost for more reliable local connections
|
||||||
|
const defaultSettings = {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`,
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'copilot-managed',
|
||||||
|
ANTHROPIC_MODEL: defaultModel,
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || defaultModel,
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || defaultModel,
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || defaultModel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
settings: defaultSettings,
|
||||||
|
mtime: Date.now(),
|
||||||
|
path: `~/.ccs/copilot.settings.json`,
|
||||||
|
exists: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||||
|
const settings = JSON.parse(content);
|
||||||
|
const stat = fs.statSync(settingsPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
settings,
|
||||||
|
mtime: stat.mtimeMs,
|
||||||
|
path: `~/.ccs/copilot.settings.json`,
|
||||||
|
exists: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/copilot/settings/raw - Save raw copilot.settings.json
|
||||||
|
* Saves the raw JSON content from the code editor
|
||||||
|
*/
|
||||||
|
router.put('/raw', (req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const { settings, expectedMtime } = req.body;
|
||||||
|
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json');
|
||||||
|
|
||||||
|
// Check for conflict if file exists and expectedMtime provided
|
||||||
|
if (fs.existsSync(settingsPath) && expectedMtime) {
|
||||||
|
const stat = fs.statSync(settingsPath);
|
||||||
|
if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) {
|
||||||
|
res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write settings file atomically
|
||||||
|
const tempPath = settingsPath + '.tmp';
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||||
|
fs.renameSync(tempPath, settingsPath);
|
||||||
|
|
||||||
|
// Also sync model mappings back to unified config
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const env = settings.env || {};
|
||||||
|
|
||||||
|
config.copilot = {
|
||||||
|
...(config.copilot ?? DEFAULT_COPILOT_CONFIG),
|
||||||
|
model: env.ANTHROPIC_MODEL || config.copilot?.model || DEFAULT_COPILOT_CONFIG.model,
|
||||||
|
opus_model: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined,
|
||||||
|
sonnet_model: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined,
|
||||||
|
haiku_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined,
|
||||||
|
};
|
||||||
|
saveUnifiedConfig(config);
|
||||||
|
|
||||||
|
const stat = fs.statSync(settingsPath);
|
||||||
|
res.json({ success: true, mtime: stat.mtimeMs });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Health Routes - System health checks and fixes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { runHealthChecks, fixHealthIssue } from '../health-service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/health - Run health checks
|
||||||
|
*/
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
const report = await runHealthChecks();
|
||||||
|
res.json(report);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json({ success: true, message: result.message });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ success: false, message: result.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Routes Aggregator - Combines all domain-specific route modules
|
||||||
|
*
|
||||||
|
* This file serves as the central entry point for all API routes,
|
||||||
|
* mounting each domain router at its appropriate path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
// Import domain routers
|
||||||
|
import profileRoutes from './profile-routes';
|
||||||
|
import configRoutes from './config-routes';
|
||||||
|
import healthRoutes from './health-routes';
|
||||||
|
import providerRoutes from './provider-routes';
|
||||||
|
import variantRoutes from './variant-routes';
|
||||||
|
import settingsRoutes from './settings-routes';
|
||||||
|
import websearchRoutes from './websearch-routes';
|
||||||
|
import cliproxyAuthRoutes from './cliproxy-auth-routes';
|
||||||
|
import cliproxyStatsRoutes from './cliproxy-stats-routes';
|
||||||
|
import copilotRoutes from './copilot-routes';
|
||||||
|
import miscRoutes from './misc-routes';
|
||||||
|
import cliproxyServerRoutes from './proxy-routes';
|
||||||
|
|
||||||
|
// Create the main API router
|
||||||
|
export const apiRoutes = Router();
|
||||||
|
|
||||||
|
// ==================== Profile & Settings ====================
|
||||||
|
// Profile CRUD, settings management, presets, accounts
|
||||||
|
apiRoutes.use('/profiles', profileRoutes);
|
||||||
|
apiRoutes.use('/settings', settingsRoutes);
|
||||||
|
apiRoutes.use('/accounts', profileRoutes);
|
||||||
|
|
||||||
|
// ==================== Unified Config ====================
|
||||||
|
// Config format, migration, secrets
|
||||||
|
apiRoutes.use('/config', configRoutes);
|
||||||
|
apiRoutes.use('/secrets', configRoutes);
|
||||||
|
|
||||||
|
// ==================== Health Checks ====================
|
||||||
|
apiRoutes.use('/health', healthRoutes);
|
||||||
|
|
||||||
|
// ==================== CLIProxy ====================
|
||||||
|
// Variants, auth, accounts, stats, status, models, error logs
|
||||||
|
apiRoutes.use('/cliproxy', variantRoutes);
|
||||||
|
apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes);
|
||||||
|
apiRoutes.use('/cliproxy', cliproxyStatsRoutes);
|
||||||
|
apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
|
||||||
|
|
||||||
|
// ==================== WebSearch ====================
|
||||||
|
apiRoutes.use('/websearch', websearchRoutes);
|
||||||
|
|
||||||
|
// ==================== Copilot ====================
|
||||||
|
apiRoutes.use('/copilot', copilotRoutes);
|
||||||
|
|
||||||
|
// ==================== CLIProxy Server Settings ====================
|
||||||
|
apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
|
||||||
|
|
||||||
|
// ==================== Misc (File API, Global Env) ====================
|
||||||
|
apiRoutes.use('/', miscRoutes);
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* Misc Routes - Generic file API and global environment variables
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCcsDir } from '../../utils/config-manager';
|
||||||
|
import { expandPath } from '../../utils/helpers';
|
||||||
|
import {
|
||||||
|
loadOrCreateUnifiedConfig,
|
||||||
|
saveUnifiedConfig,
|
||||||
|
getGlobalEnvConfig,
|
||||||
|
} from '../../config/unified-config-loader';
|
||||||
|
import { validateFilePath } from './route-helpers';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ==================== Generic File API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/file - Read a file with path validation
|
||||||
|
* Query params: path (required)
|
||||||
|
* Returns: { content: string, mtime: number, readonly: boolean, path: string }
|
||||||
|
*/
|
||||||
|
router.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 }
|
||||||
|
*/
|
||||||
|
router.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 }> }
|
||||||
|
*/
|
||||||
|
router.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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Global Environment Variables ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/global-env - Get global environment variables configuration
|
||||||
|
* Returns the global_env section from config.yaml
|
||||||
|
*/
|
||||||
|
router.get('/global-env', (_req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const config = getGlobalEnvConfig();
|
||||||
|
res.json(config);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/global-env - Update global environment variables configuration
|
||||||
|
* Updates the global_env section in config.yaml
|
||||||
|
*/
|
||||||
|
router.put('/global-env', (req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const { enabled, env } = req.body;
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
|
||||||
|
// Validate env is an object with string values
|
||||||
|
if (env !== undefined && typeof env === 'object' && env !== null) {
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
res.status(400).json({ error: `Invalid value for ${key}: must be a string` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update global_env section
|
||||||
|
config.global_env = {
|
||||||
|
enabled: enabled ?? config.global_env?.enabled ?? true,
|
||||||
|
env: env ?? config.global_env?.env ?? {},
|
||||||
|
};
|
||||||
|
|
||||||
|
saveUnifiedConfig(config);
|
||||||
|
res.json({ success: true, config: config.global_env });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Profile Routes - CRUD operations for user profiles and accounts
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 {
|
||||||
|
readConfigSafe,
|
||||||
|
writeConfig,
|
||||||
|
isConfigured,
|
||||||
|
createSettingsFile,
|
||||||
|
updateSettingsFile,
|
||||||
|
} from './route-helpers';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ==================== Profile CRUD ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/profiles - List all profiles
|
||||||
|
*/
|
||||||
|
router.get('/', (_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
|
||||||
|
*/
|
||||||
|
router.post('/', (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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reserved names
|
||||||
|
if (isReservedName(name)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: `Profile name '${name}' is reserved`,
|
||||||
|
reserved: RESERVED_PROFILE_NAMES,
|
||||||
|
});
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
router.put('/: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
|
||||||
|
*/
|
||||||
|
router.delete('/: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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 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;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Provider Routes - OpenAI-compatible provider management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import {
|
||||||
|
listOpenAICompatProviders,
|
||||||
|
getOpenAICompatProvider,
|
||||||
|
addOpenAICompatProvider,
|
||||||
|
updateOpenAICompatProvider,
|
||||||
|
removeOpenAICompatProvider,
|
||||||
|
OPENROUTER_TEMPLATE,
|
||||||
|
TOGETHER_TEMPLATE,
|
||||||
|
} from '../../cliproxy/openai-compat-manager';
|
||||||
|
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/openai-compat - List all OpenAI-compatible providers
|
||||||
|
*/
|
||||||
|
router.get('/', (_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
|
||||||
|
*/
|
||||||
|
router.get('/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
|
||||||
|
*/
|
||||||
|
router.get('/: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 }] }
|
||||||
|
*/
|
||||||
|
router.post('/', (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;
|
||||||
|
}
|
||||||
|
// Validate reserved names
|
||||||
|
if (isReservedName(name)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: `Provider name '${name}' is reserved`,
|
||||||
|
reserved: RESERVED_PROFILE_NAMES,
|
||||||
|
});
|
||||||
|
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) }
|
||||||
|
*/
|
||||||
|
router.put('/: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
|
||||||
|
*/
|
||||||
|
router.delete('/: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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Route Helpers - Shared utility functions for route handlers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../../utils/config-manager';
|
||||||
|
import { expandPath } from '../../utils/helpers';
|
||||||
|
import type { Config, Settings } from '../../types/config';
|
||||||
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
|
import { getClaudeEnvVars } from '../../cliproxy/config-generator';
|
||||||
|
|
||||||
|
/** Model mapping for API profiles */
|
||||||
|
export interface ModelMapping {
|
||||||
|
model?: string;
|
||||||
|
opusModel?: string;
|
||||||
|
sonnetModel?: string;
|
||||||
|
haikuModel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read config safely with fallback
|
||||||
|
*/
|
||||||
|
export function readConfigSafe(): Config {
|
||||||
|
try {
|
||||||
|
return loadConfig();
|
||||||
|
} catch {
|
||||||
|
return { profiles: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write config atomically
|
||||||
|
*/
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if profile is configured (has valid settings file)
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create settings file for profile
|
||||||
|
*/
|
||||||
|
export 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update settings file
|
||||||
|
*/
|
||||||
|
export 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create cliproxy variant settings
|
||||||
|
* Includes base URL and auth token for proper Claude CLI integration
|
||||||
|
*/
|
||||||
|
export 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security: Validate file path is within allowed directories
|
||||||
|
* - ~/.ccs/ directory: read/write allowed
|
||||||
|
* - ~/.claude/settings.json: read-only
|
||||||
|
*/
|
||||||
|
export 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' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Settings Routes - Settings and preset management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCcsDir, loadSettings } from '../../utils/config-manager';
|
||||||
|
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
|
||||||
|
import type { Settings } from '../../types/config';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
router.get('/: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);
|
||||||
|
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`);
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
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`);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`);
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
router.post('/: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
|
||||||
|
*/
|
||||||
|
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`);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Variant Routes - CLIProxy variant management (custom profiles)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCcsDir, loadSettings } from '../../utils/config-manager';
|
||||||
|
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
|
||||||
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
|
import { readConfigSafe, writeConfig, createCliproxySettings } from './route-helpers';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy - List cliproxy variants
|
||||||
|
*/
|
||||||
|
router.get('/', (_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
|
||||||
|
*/
|
||||||
|
router.post('/', (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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject reserved names as variant names (prevents collision with built-in providers)
|
||||||
|
if (isReservedName(name)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: `Cannot use reserved name '${name}' as variant name`,
|
||||||
|
reserved: RESERVED_PROFILE_NAMES,
|
||||||
|
});
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
router.put('/: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
|
||||||
|
*/
|
||||||
|
router.delete('/: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* WebSearch Routes - WebSearch configuration and status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import {
|
||||||
|
loadUnifiedConfig,
|
||||||
|
saveUnifiedConfig,
|
||||||
|
getWebSearchConfig,
|
||||||
|
} from '../../config/unified-config-loader';
|
||||||
|
import type { WebSearchConfig } from '../../config/unified-config-types';
|
||||||
|
import {
|
||||||
|
getWebSearchReadiness,
|
||||||
|
getGeminiCliStatus,
|
||||||
|
getGrokCliStatus,
|
||||||
|
getOpenCodeCliStatus,
|
||||||
|
} from '../../utils/websearch-manager';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/websearch - Get WebSearch configuration
|
||||||
|
* Returns: WebSearchConfig with enabled, provider, fallback
|
||||||
|
*/
|
||||||
|
router.get('/', (_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.
|
||||||
|
*/
|
||||||
|
router.put('/', (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 - supports Gemini CLI and Grok CLI
|
||||||
|
existingConfig.websearch = {
|
||||||
|
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
|
||||||
|
providers: providers
|
||||||
|
? {
|
||||||
|
gemini: {
|
||||||
|
enabled:
|
||||||
|
providers.gemini?.enabled ??
|
||||||
|
existingConfig.websearch?.providers?.gemini?.enabled ??
|
||||||
|
true,
|
||||||
|
model:
|
||||||
|
providers.gemini?.model ??
|
||||||
|
existingConfig.websearch?.providers?.gemini?.model ??
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
timeout:
|
||||||
|
providers.gemini?.timeout ??
|
||||||
|
existingConfig.websearch?.providers?.gemini?.timeout ??
|
||||||
|
55,
|
||||||
|
},
|
||||||
|
grok: {
|
||||||
|
enabled:
|
||||||
|
providers.grok?.enabled ??
|
||||||
|
existingConfig.websearch?.providers?.grok?.enabled ??
|
||||||
|
false,
|
||||||
|
timeout:
|
||||||
|
providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55,
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
enabled:
|
||||||
|
providers.opencode?.enabled ??
|
||||||
|
existingConfig.websearch?.providers?.opencode?.enabled ??
|
||||||
|
false,
|
||||||
|
model:
|
||||||
|
providers.opencode?.model ??
|
||||||
|
existingConfig.websearch?.providers?.opencode?.model ??
|
||||||
|
'opencode/grok-code',
|
||||||
|
timeout:
|
||||||
|
providers.opencode?.timeout ??
|
||||||
|
existingConfig.websearch?.providers?.opencode?.timeout ??
|
||||||
|
60,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: 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, opencodeCli, readiness }
|
||||||
|
*/
|
||||||
|
router.get('/status', (_req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const geminiCli = getGeminiCliStatus();
|
||||||
|
const grokCli = getGrokCliStatus();
|
||||||
|
const opencodeCli = getOpenCodeCliStatus();
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
opencodeCli: {
|
||||||
|
installed: opencodeCli.installed,
|
||||||
|
path: opencodeCli.path,
|
||||||
|
version: opencodeCli.version,
|
||||||
|
},
|
||||||
|
readiness: {
|
||||||
|
status: readiness.readiness,
|
||||||
|
message: readiness.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Reference in New Issue
Block a user