diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 4193711f..a541fdf5 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -93,7 +93,7 @@ function validateApiName(name: string): string | null { } /** - * Validate URL format + * Validate URL format and warn about common mistakes */ function validateUrl(url: string): string | null { if (!url) { @@ -107,6 +107,25 @@ function validateUrl(url: string): string | null { } } +/** + * Check if URL looks like it includes endpoint path (common mistake) + * Returns warning message if problematic, null if OK + */ +function getUrlWarning(url: string): string | null { + const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions']; + const lowerUrl = url.toLowerCase(); + + for (const path of problematicPaths) { + if (lowerUrl.endsWith(path)) { + return ( + `URL ends with "${path}" - Claude appends this automatically.\n` + + ` You likely want: ${url.replace(new RegExp(path + '$', 'i'), '')}` + ); + } + } + return null; +} + /** * Check if unified config mode is active */ @@ -130,11 +149,24 @@ function apiExists(name: string): boolean { } } +/** Model mapping for API profiles */ +interface ModelMapping { + default: string; + opus: string; + sonnet: string; + haiku: string; +} + /** * Create settings.json file for API profile * Includes all 4 model fields for proper Claude CLI integration */ -function createSettingsFile(name: string, baseUrl: string, apiKey: string, model: string): string { +function createSettingsFile( + name: string, + baseUrl: string, + apiKey: string, + models: ModelMapping +): string { const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${name}.settings.json`); @@ -142,10 +174,10 @@ function createSettingsFile(name: string, baseUrl: string, apiKey: string, model env: { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + ANTHROPIC_MODEL: models.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, }, }; @@ -192,7 +224,7 @@ function createApiProfileUnified( name: string, baseUrl: string, apiKey: string, - model: string + models: ModelMapping ): void { const ccsDir = path.join(os.homedir(), '.ccs'); const settingsFile = `${name}.settings.json`; @@ -203,10 +235,10 @@ function createApiProfileUnified( env: { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + ANTHROPIC_MODEL: models.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, }, }; @@ -293,9 +325,12 @@ async function handleCreate(args: string[]): Promise { // Step 2: Base URL let baseUrl = parsedArgs.baseUrl; if (!baseUrl) { - baseUrl = await InteractivePrompt.input('API Base URL (e.g., https://api.example.com)', { - validate: validateUrl, - }); + baseUrl = await InteractivePrompt.input( + 'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)', + { + validate: validateUrl, + } + ); } else { const error = validateUrl(baseUrl); if (error) { @@ -304,6 +339,23 @@ async function handleCreate(args: string[]): Promise { } } + // Check for common URL mistakes and warn + const urlWarning = getUrlWarning(baseUrl); + if (urlWarning) { + console.log(''); + console.log(warn(urlWarning)); + const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', { + default: false, + }); + if (!continueAnyway) { + // Let user re-enter URL + baseUrl = await InteractivePrompt.input('API Base URL', { + validate: validateUrl, + default: baseUrl.replace(/\/(chat\/completions|v1\/messages|messages|completions)$/i, ''), + }); + } + } + // Step 3: API Key let apiKey = parsedArgs.apiKey; if (!apiKey) { @@ -318,50 +370,127 @@ async function handleCreate(args: string[]): Promise { const defaultModel = 'claude-sonnet-4-5-20250929'; let model = parsedArgs.model; if (!model && !parsedArgs.yes) { - model = await InteractivePrompt.input('Default model', { + model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', { default: defaultModel, }); } model = model || defaultModel; + // Step 5: Model mapping for Opus/Sonnet/Haiku + // Auto-show if user entered a custom model, otherwise ask + let opusModel = model; + let sonnetModel = model; + let haikuModel = model; + + const isCustomModel = model !== defaultModel; + + if (!parsedArgs.yes) { + // If user entered custom model, auto-prompt for model mapping + // Otherwise, ask if they want to configure it + let wantCustomMapping = isCustomModel; + + if (!isCustomModel) { + console.log(''); + console.log(dim('Some API proxies route different model types to different backends.')); + wantCustomMapping = await InteractivePrompt.confirm( + 'Configure different models for Opus/Sonnet/Haiku?', + { default: false } + ); + } + + if (wantCustomMapping) { + console.log(''); + if (isCustomModel) { + console.log(dim('Configure model IDs for each tier (defaults to your model):')); + } else { + console.log(dim('Leave blank to use the default model for each.')); + } + opusModel = + (await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', { + default: model, + })) || model; + sonnetModel = + (await InteractivePrompt.input('Sonnet model (ANTHROPIC_DEFAULT_SONNET_MODEL)', { + default: model, + })) || model; + haikuModel = + (await InteractivePrompt.input('Haiku model (ANTHROPIC_DEFAULT_HAIKU_MODEL)', { + default: model, + })) || model; + } + } + + // Build model mapping + const models: ModelMapping = { + default: model, + opus: opusModel, + sonnet: sonnetModel, + haiku: haikuModel, + }; + + // Check if custom model mapping is configured + const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model; + // Create files console.log(''); console.log(info('Creating API profile...')); try { + const settingsFile = `~/.ccs/${name}.settings.json`; + if (isUnifiedMode()) { // Use unified config format - createApiProfileUnified(name, baseUrl, apiKey, model); + createApiProfileUnified(name, baseUrl, apiKey, models); console.log(''); - console.log( - infoBox( - `API: ${name}\n` + - `Config: ~/.ccs/config.yaml\n` + - `Secrets: ~/.ccs/secrets.yaml\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}`, - 'API Profile Created (Unified Config)' - ) - ); + + // Build info message + let infoMsg = + `API: ${name}\n` + + `Config: ~/.ccs/config.yaml\n` + + `Settings: ${settingsFile}\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`; + + if (hasCustomMapping) { + infoMsg += + `\n\nModel Mapping:\n` + + ` Opus: ${opusModel}\n` + + ` Sonnet: ${sonnetModel}\n` + + ` Haiku: ${haikuModel}`; + } + + console.log(infoBox(infoMsg, 'API Profile Created')); } else { // Use legacy JSON format - const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); + const settingsPath = createSettingsFile(name, baseUrl, apiKey, models); updateConfig(name, settingsPath); console.log(''); - console.log( - infoBox( - `API: ${name}\n` + - `Settings: ~/.ccs/${name}.settings.json\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}`, - 'API Profile Created' - ) - ); + + let infoMsg = + `API: ${name}\n` + + `Settings: ${settingsFile}\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`; + + if (hasCustomMapping) { + infoMsg += + `\n\nModel Mapping:\n` + + ` Opus: ${opusModel}\n` + + ` Sonnet: ${sonnetModel}\n` + + ` Haiku: ${haikuModel}`; + } + + console.log(infoBox(infoMsg, 'API Profile Created')); } + console.log(''); console.log(header('Usage')); console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); console.log(''); + console.log(header('Edit Settings')); + console.log(` ${dim('To modify env vars later:')}`); + console.log(` ${color(`nano ${settingsFile.replace('~', '$HOME')}`, 'command')}`); + console.log(''); } catch (error) { console.log(fail(`Failed to create API profile: ${(error as Error).message}`)); process.exit(1); diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 25007940..c5a16511 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -77,17 +77,34 @@ function isConfigured(profileName: string, config: Config): boolean { } } +/** Model mapping for API profiles */ +interface ModelMapping { + model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; +} + /** * Helper: Create settings file for profile */ -function createSettingsFile(name: string, baseUrl: string, apiKey: string, model?: string): string { +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 }), }, }; @@ -100,7 +117,14 @@ function createSettingsFile(name: string, baseUrl: string, apiKey: string, model */ function updateSettingsFile( name: string, - updates: { baseUrl?: string; apiKey?: string; model?: string } + updates: { + baseUrl?: string; + apiKey?: string; + model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; + } ): void { const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); @@ -129,6 +153,34 @@ function updateSettingsFile( } } + // 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'); } @@ -166,7 +218,7 @@ apiRoutes.get('/profiles', (_req: Request, res: Response) => { * POST /api/profiles - Create new profile */ apiRoutes.post('/profiles', (req: Request, res: Response): void => { - const { name, baseUrl, apiKey, model } = req.body; + 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' }); @@ -185,8 +237,13 @@ apiRoutes.post('/profiles', (req: Request, res: Response): void => { fs.mkdirSync(getCcsDir(), { recursive: true }); } - // Create settings file - const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); + // Create settings file with model mapping + const settingsPath = createSettingsFile(name, baseUrl, apiKey, { + model, + opusModel, + sonnetModel, + haikuModel, + }); // Update config config.profiles[name] = settingsPath; @@ -200,7 +257,7 @@ apiRoutes.post('/profiles', (req: Request, res: Response): void => { */ apiRoutes.put('/profiles/:name', (req: Request, res: Response): void => { const { name } = req.params; - const { baseUrl, apiKey, model } = req.body; + const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; const config = readConfigSafe(); @@ -210,7 +267,7 @@ apiRoutes.put('/profiles/:name', (req: Request, res: Response): void => { } try { - updateSettingsFile(name, { baseUrl, apiKey, model }); + updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); res.json({ name, updated: true }); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -779,3 +836,190 @@ apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => { keys: Object.keys(secrets), // Only key names, not values }); }); + +// ==================== Generic File API (Issue #73) ==================== + +/** + * Security: Validate file path is within allowed directories + * - ~/.ccs/ directory: read/write allowed + * - ~/.claude/settings.json: read-only + */ +function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string } { + const expandedPath = expandPath(filePath); + const normalizedPath = path.normalize(expandedPath); + const ccsDir = getCcsDir(); + const claudeSettingsPath = expandPath('~/.claude/settings.json'); + + // Check if path is within ~/.ccs/ + if (normalizedPath.startsWith(ccsDir)) { + // Block access to sensitive subdirectories + const relativePath = normalizedPath.slice(ccsDir.length); + if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) { + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; + } + return { valid: true, readonly: false }; + } + + // Allow read-only access to ~/.claude/settings.json + if (normalizedPath === claudeSettingsPath) { + return { valid: true, readonly: true }; + } + + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; +} + +/** + * GET /api/file - Read a file with path validation + * Query params: path (required) + * Returns: { content: string, mtime: number, readonly: boolean, path: string } + */ +apiRoutes.get('/file', (req: Request, res: Response): void => { + const filePath = req.query.path as string; + + if (!filePath) { + res.status(400).json({ error: 'Missing required query parameter: path' }); + return; + } + + const validation = validateFilePath(filePath); + if (!validation.valid) { + res.status(403).json({ error: validation.error }); + return; + } + + const expandedPath = expandPath(filePath); + + if (!fs.existsSync(expandedPath)) { + res.status(404).json({ error: 'File not found' }); + return; + } + + try { + const stat = fs.statSync(expandedPath); + const content = fs.readFileSync(expandedPath, 'utf8'); + + res.json({ + content, + mtime: stat.mtime.getTime(), + readonly: validation.readonly, + path: expandedPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/file - Write a file with conflict detection and backup + * Query params: path (required) + * Body: { content: string, expectedMtime?: number } + * Returns: { success: true, mtime: number, backupPath?: string } + */ +apiRoutes.put('/file', (req: Request, res: Response): void => { + const filePath = req.query.path as string; + const { content, expectedMtime } = req.body; + + if (!filePath) { + res.status(400).json({ error: 'Missing required query parameter: path' }); + return; + } + + if (typeof content !== 'string') { + res.status(400).json({ error: 'Missing required field: content' }); + return; + } + + const validation = validateFilePath(filePath); + if (!validation.valid) { + res.status(403).json({ error: validation.error }); + return; + } + + if (validation.readonly) { + res.status(403).json({ error: 'File is read-only' }); + return; + } + + const expandedPath = expandPath(filePath); + const ccsDir = getCcsDir(); + + // Conflict detection (if file exists and expectedMtime provided) + if (fs.existsSync(expandedPath) && expectedMtime !== undefined) { + const stat = fs.statSync(expandedPath); + if (stat.mtime.getTime() !== expectedMtime) { + res.status(409).json({ + error: 'File modified externally', + currentMtime: stat.mtime.getTime(), + }); + return; + } + } + + try { + // Create backup if file exists + let backupPath: string | undefined; + if (fs.existsSync(expandedPath)) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const filename = path.basename(expandedPath); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`); + fs.copyFileSync(expandedPath, backupPath); + } + + // Ensure parent directory exists + const parentDir = path.dirname(expandedPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + + // Write atomically + const tempPath = expandedPath + '.tmp'; + fs.writeFileSync(tempPath, content); + fs.renameSync(tempPath, expandedPath); + + const newStat = fs.statSync(expandedPath); + res.json({ + success: true, + mtime: newStat.mtime.getTime(), + backupPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/files - List editable files in ~/.ccs/ + * Returns: { files: Array<{ name: string, path: string, mtime: number }> } + */ +apiRoutes.get('/files', (_req: Request, res: Response): void => { + const ccsDir = getCcsDir(); + + if (!fs.existsSync(ccsDir)) { + res.json({ files: [] }); + return; + } + + try { + const entries = fs.readdirSync(ccsDir, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => { + const filePath = path.join(ccsDir, entry.name); + const stat = fs.statSync(filePath); + return { + name: entry.name, + path: `~/.ccs/${entry.name}`, + mtime: stat.mtime.getTime(), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ files }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/ui/bun.lock b/ui/bun.lock index 14e0041b..c677c243 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -12,6 +12,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", @@ -23,11 +24,13 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.556.0", + "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", "react-router-dom": "^7.10.1", + "react-simple-code-editor": "^0.14.1", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -243,6 +246,8 @@ "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], @@ -261,6 +266,8 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], @@ -387,6 +394,8 @@ "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -677,6 +686,8 @@ "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -701,6 +712,8 @@ "react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="], + "react-simple-code-editor": ["react-simple-code-editor@0.14.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow=="], + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], @@ -799,6 +812,8 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/ui/package.json b/ui/package.json index acd632e1..9b90a795 100644 --- a/ui/package.json +++ b/ui/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", @@ -34,11 +35,13 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.556.0", + "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", "react-router-dom": "^7.10.1", + "react-simple-code-editor": "^0.14.1", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c9ab07be..8b4801b3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -43,16 +43,18 @@ function Layout() { return ( -
-
+
+
- }> - - +
+ }> + + +
diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx new file mode 100644 index 00000000..d379a7c9 --- /dev/null +++ b/ui/src/components/code-editor.tsx @@ -0,0 +1,237 @@ +/** + * Code Editor Component + * Lightweight JSON editor with syntax highlighting, line numbers, and validation + * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB) + */ + +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import Editor from 'react-simple-code-editor'; +import { Highlight, themes } from 'prism-react-renderer'; +import { useTheme } from '@/hooks/use-theme'; +import { cn } from '@/lib/utils'; +import { isSensitiveKey } from '@/lib/sensitive-keys'; +import { AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface CodeEditorProps { + value: string; + onChange: (value: string) => void; + language?: 'json' | 'yaml'; + readonly?: boolean; + className?: string; + minHeight?: string; +} + +interface ValidationResult { + valid: boolean; + error?: string; + line?: number; +} + +/** + * Validate JSON and extract error location + */ +function validateJson(code: string): ValidationResult { + if (!code.trim()) { + return { valid: true }; + } + + try { + JSON.parse(code); + return { valid: true }; + } catch (e) { + const error = e as SyntaxError; + const message = error.message; + + // Try to extract line number from error message + // Format: "... at position X" or "... at line Y column Z" + const posMatch = message.match(/position (\d+)/); + if (posMatch) { + const pos = parseInt(posMatch[1], 10); + const lines = code.substring(0, pos).split('\n'); + return { + valid: false, + error: message, + line: lines.length, + }; + } + + return { + valid: false, + error: message, + }; + } +} + +export function CodeEditor({ + value, + onChange, + language = 'json', + readonly = false, + className, + minHeight = '300px', +}: CodeEditorProps) { + const { isDark } = useTheme(); + const [isFocused, setIsFocused] = useState(false); + const [isMasked, setIsMasked] = useState(true); + // Force Editor remount when theme changes (works around react-simple-code-editor caching) + const [editorKey, setEditorKey] = useState(0); + const isFirstRender = useRef(true); + + useEffect(() => { + // Skip first render, only trigger on theme changes + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + setEditorKey((k) => k + 1); + }, [isDark]); + + // Validate on every change for JSON + const validation = useMemo(() => { + if (language === 'json') { + return validateJson(value); + } + return { valid: true }; + }, [value, language]); + + // Highlight function using prism-react-renderer + // Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor + const highlightCode = useCallback( + (code: string) => ( + + {({ tokens, getLineProps, getTokenProps }) => { + let nextValueIsSensitive = false; + + return ( + <> + {tokens.map((line, i) => ( +
+ {line.map((token, key) => { + let isSensitive = false; + + // Check for sensitive keys + if (token.types.includes('property')) { + const content = token.content.replace(/['"]/g, ''); + // Use shared sensitive key detection utility + if (isSensitiveKey(content)) { + nextValueIsSensitive = true; + } else { + nextValueIsSensitive = false; + } + } + // Apply masking to values following sensitive keys + else if ( + (token.types.includes('string') || + token.types.includes('number') || + token.types.includes('boolean')) && + nextValueIsSensitive + ) { + isSensitive = true; + // Consumes the flag for this value + nextValueIsSensitive = false; + } + // Reset flag on commas or new keys (handled by property check), + // but persist through colons and whitespace + else if (token.types.includes('punctuation')) { + if ( + token.content !== ':' && + token.content !== '[' && + token.content !== '{' + ) { + nextValueIsSensitive = false; + } + } + + const tokenProps = getTokenProps({ token }); + + if (isSensitive && isMasked) { + tokenProps.className = cn( + tokenProps.className, + 'blur-[3px] select-none opacity-70 transition-all duration-200' + ); + } + + return ; + })} +
+ ))} + + ); + }} +
+ ), + [isDark, language, validation.line, isMasked] + ); + + return ( +
+ {/* Editor container */} +
+ {} : onChange} + highlight={highlightCode} + key={editorKey} + padding={12} + disabled={readonly} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + textareaClassName={cn( + 'focus:outline-none font-mono text-sm', + readonly && 'cursor-not-allowed' + )} + preClassName="font-mono text-sm" + style={{ + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.875rem', + minHeight, + }} + /> + + {/* Secrets Toggle Overlay */} +
+ +
+
+ + {/* Validation status */} +
+ {validation.valid ? ( + + + Valid {language.toUpperCase()} + + ) : ( + + + {validation.error} + {validation.line && ` (line ${validation.line})`} + + )} + {readonly && (Read-only)} +
+
+ ); +} diff --git a/ui/src/components/localhost-disclaimer.tsx b/ui/src/components/localhost-disclaimer.tsx index 23a1c778..78d5c6e1 100644 --- a/ui/src/components/localhost-disclaimer.tsx +++ b/ui/src/components/localhost-disclaimer.tsx @@ -1,30 +1,13 @@ import { Shield, X } from 'lucide-react'; import { useState } from 'react'; -import { useSidebar } from '@/hooks/use-sidebar'; export function LocalhostDisclaimer() { const [dismissed, setDismissed] = useState(false); - const { state, isMobile } = useSidebar(); if (dismissed) return null; - // Calculate the left margin based on sidebar state - // When expanded: sidebar width is 16rem - // When collapsed: sidebar width is 3rem - // On mobile: sidebar is overlay, no margin needed - const getLeftMargin = () => { - if (isMobile) return '0'; - return state === 'expanded' ? '16rem' : '3rem'; - }; - return ( -
+
diff --git a/ui/src/components/profile-create-dialog.tsx b/ui/src/components/profile-create-dialog.tsx new file mode 100644 index 00000000..5506bd45 --- /dev/null +++ b/ui/src/components/profile-create-dialog.tsx @@ -0,0 +1,344 @@ +/** + * Profile Create Dialog Component + * Modal dialog with tabbed interface for creating new API profiles + * Includes Quick Start templates and advanced model configuration + */ + +import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { useCreateProfile } from '@/hooks/use-profiles'; +import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; + +const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; + +const schema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'), + baseUrl: z.string().url('Invalid URL format'), + apiKey: z.string().min(1, 'API key is required'), + model: z.string().optional(), + opusModel: z.string().optional(), + sonnetModel: z.string().optional(), + haikuModel: z.string().optional(), +}); + +type FormData = z.infer; + +interface ProfileCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: (name: string) => void; +} + +// Common URL mistakes to warn about +const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions']; + +export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCreateDialogProps) { + const createMutation = useCreateProfile(); + const [activeTab, setActiveTab] = useState('basic'); + const [urlWarning, setUrlWarning] = useState(null); + const [showApiKey, setShowApiKey] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + watch, + reset, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + baseUrl: '', + apiKey: '', + model: '', + opusModel: '', + sonnetModel: '', + haikuModel: '', + }, + }); + + const baseUrlValue = watch('baseUrl'); + + // Reset form when dialog opens + useEffect(() => { + if (open) { + reset(); + setActiveTab('basic'); + setUrlWarning(null); + setShowApiKey(false); + } + }, [open, reset]); + + // Check for common URL mistakes + useEffect(() => { + if (baseUrlValue) { + const lowerUrl = baseUrlValue.toLowerCase(); + for (const path of PROBLEMATIC_PATHS) { + if (lowerUrl.endsWith(path)) { + const suggestedUrl = baseUrlValue.replace(new RegExp(path + '$', 'i'), ''); + setUrlWarning( + `URL ends with "${path}" - Claude appends this automatically. You likely want: ${suggestedUrl}` + ); + return; + } + } + } + setUrlWarning(null); + }, [baseUrlValue]); + + const onSubmit = async (data: FormData) => { + try { + await createMutation.mutateAsync(data); + toast.success(`Profile "${data.name}" created`); + onSuccess(data.name); + onOpenChange(false); + } catch (error) { + toast.error((error as Error).message || 'Failed to create profile'); + } + }; + + const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey; + const hasModelErrors = + !!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel; + + return ( + + + + + + Create API Profile + + Configure a custom API endpoint for Claude Code. + + +
+ +
+ + + Basic Information + {hasBasicErrors && ( + + )} + + + Model Configuration + {hasModelErrors && ( + + )} + + +
+ +
+ +
+ {/* Name */} +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : ( +

+ Used in CLI:{' '} + + ccs my-api "prompt" + +

+ )} +
+ + {/* Base URL */} +
+ + + {errors.baseUrl ? ( +

{errors.baseUrl.message}

+ ) : urlWarning ? ( +
+ + {urlWarning} +
+ ) : ( +

+ The endpoint that accepts OpenAI-compatible and Anthropic requests +

+ )} +
+ + {/* API Key */} +
+ +
+ + +
+ {errors.apiKey && ( +

{errors.apiKey.message}

+ )} +
+
+
+ + +
+ +
+

Model Mapping

+

+ Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers + to the specific models supported by your API provider. +

+
+
+ +
+
+ + +

+ Fallback model if no specific tier is requested +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + + + + +
+
+
+
+ ); +} diff --git a/ui/src/components/profile-dialog.tsx b/ui/src/components/profile-dialog.tsx index 3734c5ce..183f2f24 100644 --- a/ui/src/components/profile-dialog.tsx +++ b/ui/src/components/profile-dialog.tsx @@ -1,8 +1,10 @@ /** * Profile Dialog Component * Phase 03: REST API Routes & CRUD + * Updated: Added model mapping fields for Opus/Sonnet/Haiku */ +import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -12,6 +14,9 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles'; import type { Profile } from '@/lib/api-client'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; const schema = z.object({ name: z @@ -21,6 +26,9 @@ const schema = z.object({ baseUrl: z.string().url('Invalid URL'), apiKey: z.string().min(10, 'API key must be at least 10 characters'), model: z.string().optional(), + opusModel: z.string().optional(), + sonnetModel: z.string().optional(), + haikuModel: z.string().optional(), }); type FormData = z.infer; @@ -34,12 +42,14 @@ interface ProfileDialogProps { export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { const createMutation = useCreateProfile(); const updateMutation = useUpdateProfile(); + const [showModelMapping, setShowModelMapping] = useState(false); const { register, handleSubmit, formState: { errors }, reset, + watch, } = useForm({ resolver: zodResolver(schema), defaultValues: profile @@ -48,10 +58,30 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { baseUrl: '', apiKey: '', model: '', + opusModel: '', + sonnetModel: '', + haikuModel: '', } : undefined, }); + // Watch model field to auto-expand model mapping when custom model is entered + const modelValue = watch('model'); + + useEffect(() => { + // Auto-show model mapping if user enters a custom model (not default) + if (modelValue && modelValue !== DEFAULT_MODEL && modelValue.trim() !== '') { + setShowModelMapping(true); + } + }, [modelValue]); + + // Reset state when dialog opens/closes + useEffect(() => { + if (!open) { + setShowModelMapping(false); + } + }, [open]); + const onSubmit = async (data: FormData) => { try { if (profile) { @@ -62,6 +92,9 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { baseUrl: data.baseUrl, apiKey: data.apiKey, model: data.model, + opusModel: data.opusModel, + sonnetModel: data.sonnetModel, + haikuModel: data.haikuModel, }, }); } else { @@ -78,7 +111,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { return ( - + {profile ? 'Edit Profile' : 'Create API Profile'} @@ -104,8 +137,72 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
- - + + +

+ Leave blank to use: {DEFAULT_MODEL} +

+
+ + {/* Model Mapping Section */} +
+ + + {showModelMapping && ( +
+

+ Configure different model IDs for each tier. Useful for API proxies that route + different model types to different backends. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )}
diff --git a/ui/src/components/profile-editor.tsx b/ui/src/components/profile-editor.tsx new file mode 100644 index 00000000..8808ee00 --- /dev/null +++ b/ui/src/components/profile-editor.tsx @@ -0,0 +1,506 @@ +/** + * Profile Editor Component + * Inline editor for API profile settings with 2-column layout (Friendly UI + Raw JSON) + */ + +import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { MaskedInput } from '@/components/ui/masked-input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-react'; +import { toast } from 'sonner'; +import { CopyButton } from '@/components/ui/copy-button'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; + +// Lazy load CodeEditor to reduce initial bundle size +const CodeEditor = lazy(() => + import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +interface Settings { + env?: Record; +} + +interface SettingsResponse { + profile: string; + settings: Settings; + mtime: number; + path: string; +} + +interface ProfileEditorProps { + profileName: string; + onDelete?: () => void; +} + +export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { + const [localEdits, setLocalEdits] = useState>({}); + const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [newEnvKey, setNewEnvKey] = useState(''); + const queryClient = useQueryClient(); + + // Fetch settings for selected profile + const { data, isLoading, refetch } = useQuery({ + queryKey: ['settings', profileName], + queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), + }); + + // Derive raw JSON content + const settings = data?.settings; + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits; + } + if (settings) { + return JSON.stringify(settings, null, 2); + } + return ''; + }, [rawJsonEdits, settings]); + + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + + // Derive current settings by merging original data with local edits + // Prioritize rawJsonEdits if available + const currentSettings = useMemo((): Settings | undefined => { + if (rawJsonEdits !== null) { + try { + return JSON.parse(rawJsonEdits); + } catch { + // If invalid JSON, fall back to undefined or partial state + // The UI will likely show empty or potentially broken state if JSON is invalid, + // but the Raw Editor will show the error. + } + } + + if (!settings) return undefined; + return { + ...settings, + env: { + ...settings.env, + ...localEdits, + }, + }; + }, [settings, localEdits, rawJsonEdits]); + + // Sync Visual Editor changes to Raw JSON + const updateEnvValue = (key: string, value: string) => { + const newEnv = { ...(currentSettings?.env || {}), [key]: value }; + + // Update local edits + setLocalEdits((prev) => ({ + ...prev, + [key]: value, + })); + + // Update rawJsonEdits to keep sync + const newSettings = { ...currentSettings, env: newEnv }; + setRawJsonEdits(JSON.stringify(newSettings, null, 2)); + }; + + const addNewEnvVar = () => { + if (!newEnvKey.trim()) return; + const key = newEnvKey.trim(); + const newEnv = { ...(currentSettings?.env || {}), [key]: '' }; + + setLocalEdits((prev) => ({ + ...prev, + [key]: '', + })); + + const newSettings = { ...currentSettings, env: newEnv }; + setRawJsonEdits(JSON.stringify(newSettings, null, 2)); + + setNewEnvKey(''); + }; + + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + + // Check if there are unsaved changes + const hasChanges = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits !== JSON.stringify(settings, null, 2); + } + return Object.keys(localEdits).length > 0; + }, [rawJsonEdits, localEdits, settings]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + let settingsToSave: Settings; + + try { + // Always save from rawJsonContent as it's the source of truth + settingsToSave = JSON.parse(rawJsonContent); + } catch { + // Fallback (should typically not happen if validation is correct) + settingsToSave = { + ...data?.settings, + env: { + ...data?.settings?.env, + ...localEdits, + }, + }; + } + + const res = await fetch(`/api/settings/${profileName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: settingsToSave, + expectedMtime: data?.mtime, + }), + }); + + if (res.status === 409) { + throw new Error('CONFLICT'); + } + + if (!res.ok) { + throw new Error('Failed to save'); + } + + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + setLocalEdits({}); + setRawJsonEdits(null); + toast.success('Settings saved'); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error(error.message); + } + }, + }); + + const handleSave = () => { + saveMutation.mutate(); + }; + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetch(); + saveMutation.mutate(); + } else { + setLocalEdits({}); + setRawJsonEdits(null); + } + }; + + const isSensitiveKey = (key: string): boolean => { + const sensitivePatterns = [ + /^ANTHROPIC_AUTH_TOKEN$/, + /_API_KEY$/, + /_AUTH_TOKEN$/, + /^API_KEY$/, + /^AUTH_TOKEN$/, + /_SECRET$/, + /^SECRET$/, + ]; + return sensitivePatterns.some((pattern) => pattern.test(key)); + }; + + // Reset state when profile changes + const profileKey = profileName; + + // Render Left Column Content (Environment + Info + Usage) + const renderFriendlyUI = () => ( +
+ +
+ + + Environment Variables + + + Info & Usage + + +
+ +
+ + {/* Scrollable Environment Variables List */} + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + <> + {Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {isSensitiveKey(key) ? ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm h-8" + /> + ) : ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm h-8" + /> + )} +
+ ))} + + ) : ( +
+

No environment variables configured.

+

+ Add variables using the input below or edit the JSON directly. +

+
+ )} +
+
+ + {/* Fixed Add Input at Bottom */} +
+ +
+ setNewEnvKey(e.target.value.toUpperCase())} + className="font-mono text-sm h-8" + onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} + /> + +
+
+
+ + + +
+ {/* Profile Information */} +
+

+ + Profile Information +

+
+ {data && ( + <> +
+ Profile Name + {data.profile} +
+
+ File Path +
+ + {data.path} + + +
+
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+ + )} +
+
+ + {/* Usage */} +
+

Quick Usage

+
+
+ +
+ + ccs {profileName} "prompt" + + +
+
+
+ +
+ + ccs default {profileName} + + +
+
+
+
+
+
+
+
+
+
+ ); + + // Render Right Column Content (Raw JSON Editor) + const renderRawEditor = () => ( + + + Loading editor... +
+ } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+
+ +
+
+
+ + ); + + return ( +
+ {/* Header */} +
+
+
+

{profileName}

+ {data && ( + + {data.path.replace(/^.*\//, '')} + + )} +
+ {data && ( +

+ Last modified: {new Date(data.mtime).toLocaleString()} +

+ )} +
+
+ + {onDelete && ( + + )} + +
+
+ + {isLoading ? ( +
+ + Loading settings... +
+ ) : ( + // Split Layout (40% Left / 60% Right) +
+ {/* Left Column: Friendly UI */} +
{renderFriendlyUI()}
+ + {/* Right Column: Raw Editor */} +
+
+ + + Raw Configuration (JSON) + +
+ {renderRawEditor()} +
+
+ )} + + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> +
+ ); +} diff --git a/ui/src/components/settings-dialog.tsx b/ui/src/components/settings-dialog.tsx index d4b0f3b9..624ce907 100644 --- a/ui/src/components/settings-dialog.tsx +++ b/ui/src/components/settings-dialog.tsx @@ -1,10 +1,10 @@ /** * Settings Dialog Component * Reusable dialog for editing profile environment variables - * Features: masked inputs for sensitive keys, conflict detection, save/cancel + * Features: masked inputs for sensitive keys, conflict detection, save/cancel, raw JSON editor */ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Dialog, @@ -18,9 +18,14 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { MaskedInput } from '@/components/ui/masked-input'; import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Save, X, Loader2 } from 'lucide-react'; +import { Save, X, Loader2, Code2 } from 'lucide-react'; import { toast } from 'sonner'; +// Lazy load CodeEditor to reduce initial bundle size +const CodeEditor = lazy(() => + import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) +); + interface Settings { env?: Record; } @@ -55,6 +60,8 @@ function SettingsDialogContent({ }) { const [localEdits, setLocalEdits] = useState>({}); const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [activeTab, setActiveTab] = useState('env'); const queryClient = useQueryClient(); // Fetch settings for selected profile @@ -63,6 +70,23 @@ function SettingsDialogContent({ queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), }); + // Derive raw JSON content: use edits if available, otherwise serialize from data + const settings = data?.settings; + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits; + } + if (settings) { + return JSON.stringify(settings, null, 2); + } + return ''; + }, [rawJsonEdits, settings]); + + // Update raw JSON when user edits + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + // Derive current settings by merging original data with local edits const currentSettings = useMemo((): Settings | undefined => { const settings = data?.settings; @@ -76,16 +100,39 @@ function SettingsDialogContent({ }; }, [data?.settings, localEdits]); + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + // Save mutation const saveMutation = useMutation({ mutationFn: async () => { - const settingsToSave: Settings = { - ...data?.settings, - env: { - ...data?.settings?.env, - ...localEdits, - }, - }; + let settingsToSave: Settings; + + // Determine what to save based on active tab + if (activeTab === 'raw') { + // Parse raw JSON content + try { + settingsToSave = JSON.parse(rawJsonContent); + } catch { + throw new Error('Invalid JSON'); + } + } else { + // Use form-based edits + settingsToSave = { + ...data?.settings, + env: { + ...data?.settings?.env, + ...localEdits, + }, + }; + } const res = await fetch(`/api/settings/${profileName}`, { method: 'PUT', @@ -174,7 +221,11 @@ function SettingsDialogContent({
) : (
- + Environment + + + Raw JSON + + + + + Loading editor... +
+ } + > + + + + @@ -252,7 +328,10 @@ function SettingsDialogContent({ - + + +

{hasCopied ? 'Copied!' : label}

+
+ + + ); +} diff --git a/ui/src/components/ui/select.tsx b/ui/src/components/ui/select.tsx new file mode 100644 index 00000000..fad1c32d --- /dev/null +++ b/ui/src/components/ui/select.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 4a8932a0..40a6a0c7 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -31,12 +31,18 @@ export interface CreateProfile { baseUrl: string; apiKey: string; model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; } export interface UpdateProfile { baseUrl?: string; apiKey?: string; model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; } export interface Variant { diff --git a/ui/src/lib/sensitive-keys.ts b/ui/src/lib/sensitive-keys.ts new file mode 100644 index 00000000..c4f1cc98 --- /dev/null +++ b/ui/src/lib/sensitive-keys.ts @@ -0,0 +1,35 @@ +/** + * Sensitive Key Detection Utilities (UI) + * + * Re-exports from main package for use in UI components. + * Patterns detect API keys, tokens, passwords, etc. + */ + +/** + * Patterns that match sensitive keys (API keys, tokens, passwords). + * More specific than substring matching to avoid false positives. + */ +export const SENSITIVE_KEY_PATTERNS = [ + /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token + /_API_KEY$/, // Keys ending with _API_KEY + /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN + /_SECRET$/, // Keys ending with _SECRET + /_SECRET_KEY$/, // Keys ending with _SECRET_KEY + /^API_KEY$/, // Exact match for API_KEY + /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN + /^SECRET$/, // Exact match for SECRET + /_PASSWORD$/, // Keys ending with _PASSWORD + /^PASSWORD$/, // Exact match for PASSWORD + /_CREDENTIAL$/, // Keys ending with _CREDENTIAL + /_PRIVATE_KEY$/, // Keys ending with _PRIVATE_KEY +]; + +/** + * Check if a key name contains a secret/sensitive value. + * + * @param key - Environment variable key name + * @returns true if the key likely contains sensitive data + */ +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index f0f03dc1..dc2c313b 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -96,230 +96,228 @@ export function AnalyticsPage() { }, []); return ( -
-
- {/* Header */} -
-
-

Analytics

-

Track usage & insights

-
-
- - {lastUpdatedText && ( - - Updated {lastUpdatedText} - - )} - -
+
+ {/* Header */} +
+
+

Analytics

+

Track usage & insights

+
+ + {lastUpdatedText && ( + + Updated {lastUpdatedText} + + )} + +
+
- {/* Summary Cards */} - + {/* Summary Cards */} + - {/* Main Content */} -
- {/* Usage Trend Chart - Full Width */} - + {/* Main Content */} +
+ {/* Usage Trend Chart - Full Width */} + + + + + Usage Trends + + + + + + + + {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */} +
+ {/* Cost by Model - 4/10 width with breakdown */} + - - Usage Trends + + Cost by Model - - + + {isModelsLoading ? ( + + ) : ( +
+ {[...(models || [])] + .sort((a, b) => b.cost - a.cost) + .map((model) => ( + + ))} + {/* Legend */} +
+ +
+ Input + + +
+ Output + + +
+ Cache Write + + +
+ Cache Read + +
+
+ )} - {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */} -
- {/* Cost by Model - 4/10 width with breakdown */} - - - - - Cost by Model - - - - {isModelsLoading ? ( - - ) : ( -
- {[...(models || [])] - .sort((a, b) => b.cost - a.cost) - .map((model) => ( - - ))} - {/* Legend */} -
- -
- Input - - -
- Output - - -
- Cache Write - - -
- Cache Read - -
-
- )} - - - - {/* Model Distribution - 2/10 width */} - - - - - Model Usage - - - - - - - - {/* Session Stats - 2/10 width */} - - - {/* Usage Insights - 2/10 width */} - -
- - {/* Model Details Popover - positioned at cursor */} - !open && handlePopoverClose()}> - -
+ + + + Model Usage + + + + - - - {selectedModel && } - - + + + + {/* Session Stats - 2/10 width */} + + + {/* Usage Insights - 2/10 width */} +
+ + {/* Model Details Popover - positioned at cursor */} + !open && handlePopoverClose()}> + +
+ + + {selectedModel && } + +
); diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 74c0e72b..cde4a4d6 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -1,66 +1,333 @@ /** - * API Profiles Page - * Phase 03: REST API Routes & CRUD + * API Profiles Page - Master-Detail Layout + * Comprehensive profile management with inline editing */ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { Button } from '@/components/ui/button'; -import { Plus } from 'lucide-react'; -import { ProfilesTable } from '@/components/profiles-table'; -import { ProfileDialog } from '@/components/profile-dialog'; -import { SettingsDialog } from '@/components/settings-dialog'; -import { useProfiles } from '@/hooks/use-profiles'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { + Plus, + Search, + Settings2, + Trash2, + CheckCircle2, + AlertCircle, + Server, + ExternalLink, + FileJson, +} from 'lucide-react'; +import { ProfileEditor } from '@/components/profile-editor'; +import { ProfileCreateDialog } from '@/components/profile-create-dialog'; +import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; +import { ConfirmDialog } from '@/components/confirm-dialog'; import type { Profile } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { CopyButton } from '@/components/ui/copy-button'; export function ApiPage() { - const [dialogOpen, setDialogOpen] = useState(false); - const [editingProfile, setEditingProfile] = useState(null); - const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); - const [settingsProfileName, setSettingsProfileName] = useState(null); const { data, isLoading } = useProfiles(); + const deleteMutation = useDeleteProfile(); + const [selectedProfile, setSelectedProfile] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [isCreateDialogOpen, setCreateDialogOpen] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); - const handleEditSettings = (profile: Profile) => { - setSettingsProfileName(profile.name); - setSettingsDialogOpen(true); + // Memoize profiles to maintain stable reference + const profiles = useMemo(() => data?.profiles || [], [data?.profiles]); + + // Filter profiles by search + const filteredProfiles = useMemo( + () => profiles.filter((p) => p.name.toLowerCase().includes(searchQuery.toLowerCase())), + [profiles, searchQuery] + ); + + // Compute effective selected profile (auto-select first if none selected) + const effectiveSelectedProfile = useMemo(() => { + if (selectedProfile && profiles.some((p) => p.name === selectedProfile)) { + return selectedProfile; + } + return profiles.length > 0 ? profiles[0].name : null; + }, [selectedProfile, profiles]); + + // Handle profile deletion + const handleDelete = (name: string) => { + deleteMutation.mutate(name, { + onSuccess: () => { + if (selectedProfile === name) { + setSelectedProfile(null); + } + setDeleteConfirm(null); + }, + }); }; - const handleCloseDialog = () => { - setDialogOpen(false); - setEditingProfile(null); + // Handle create success + const handleCreateSuccess = (name: string) => { + setCreateDialogOpen(false); + setSelectedProfile(name); }; - const handleCloseSettingsDialog = () => { - setSettingsDialogOpen(false); - setSettingsProfileName(null); - }; + const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile); return ( -
-
-
-

API Profiles

-

- Manage custom API profiles for Claude CLI -

+
+ {/* Left Panel - Profiles List */} +
+ {/* Header */} +
+
+
+ +

API Profiles

+
+ +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + /> +
- + + {/* Profile List */} + + {isLoading ? ( +
Loading profiles...
+ ) : filteredProfiles.length === 0 ? ( +
+ {profiles.length === 0 ? ( +
+ +
+

No API profiles yet

+

+ Create your first profile to connect to custom API endpoints +

+
+ +
+ ) : ( +

+ No profiles match "{searchQuery}" +

+ )} +
+ ) : ( +
+ {filteredProfiles.map((profile) => ( + { + setSelectedProfile(profile.name); + }} + onDelete={() => setDeleteConfirm(profile.name)} + /> + ))} +
+ )} +
+ + {/* Footer Stats */} + {profiles.length > 0 && ( +
+
+ + {profiles.length} profile{profiles.length !== 1 ? 's' : ''} + + + + {profiles.filter((p) => p.configured).length} configured + +
+
+ )}
- {isLoading ? ( -
Loading profiles...
- ) : ( - - )} + {/* Right Panel - Editor */} +
+ {selectedProfileData ? ( + setDeleteConfirm(selectedProfileData.name)} + /> + ) : ( + { + setCreateDialogOpen(true); + }} + /> + )} +
- - + + {/* Delete Confirmation */} + deleteConfirm && handleDelete(deleteConfirm)} + onCancel={() => setDeleteConfirm(null)} />
); } + +/** Profile list item component */ +function ProfileListItem({ + profile, + isSelected, + onSelect, + onDelete, +}: { + profile: Profile; + isSelected: boolean; + onSelect: () => void; + onDelete: () => void; +}) { + return ( +
+ {/* Status indicator */} + {profile.configured ? ( + + ) : ( + + )} + + {/* Profile info */} +
+
{profile.name}
+
+
+ {profile.settingsPath} +
+ +
+
+ + {/* Actions */} + +
+ ); +} + +/** Empty state when no profile is selected */ +function EmptyState({ onCreateClick }: { onCreateClick: () => void }) { + return ( +
+
+ +

API Profile Manager

+

+ Configure custom API endpoints for Claude CLI. Connect to proxy services like copilot-api, + OpenRouter, or your own API backend. +

+ +
+ + + + +
+

+ What you can configure: +

+
    +
  • + + URL + + Custom API base URL endpoint +
  • +
  • + + Auth + + API key or authentication token +
  • +
  • + + Models + + Model mapping for Opus/Sonnet/Haiku +
  • +
+
+ + +
+
+
+ ); +}