From b86cdd6644907b1c3608aae4ec261cc4f716680f Mon Sep 17 00:00:00 2001 From: catlog22 Date: Thu, 8 Jan 2026 14:15:32 +0800 Subject: [PATCH] feat(cli-settings): Implement CLI settings management and routes - Added CLI settings file manager to handle endpoint configurations. - Introduced API routes for creating, updating, deleting, and listing CLI settings. - Enhanced session discovery for OpenCode with improved storage structure. - Updated command building logic for OpenCode and Claude to support new settings. - Added validation and sanitization for endpoint IDs and settings. - Implemented functionality to toggle endpoint enabled status and retrieve executable settings paths. --- ccw/src/commands/cli.ts | 5 - ccw/src/config/cli-settings-manager.ts | 359 +++++++++++++++ ccw/src/core/routes/cli-settings-routes.ts | 232 ++++++++++ ccw/src/core/server.ts | 3 + .../dashboard-js/views/api-settings.js | 413 +++++++++++++++++- ccw/src/tools/cli-config-manager.ts | 4 +- ccw/src/tools/cli-executor-core.ts | 4 +- ccw/src/tools/cli-executor-utils.ts | 26 +- ccw/src/tools/native-session-discovery.ts | 249 +++++++---- ccw/src/types/cli-settings.ts | 137 ++++++ 10 files changed, 1323 insertions(+), 109 deletions(-) create mode 100644 ccw/src/config/cli-settings-manager.ts create mode 100644 ccw/src/core/routes/cli-settings-routes.ts create mode 100644 ccw/src/types/cli-settings.ts diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index 3236de5a..55d749e1 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -561,11 +561,6 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec } else if (optionPrompt) { // Use --prompt/-p option (preferred for multi-line) finalPrompt = optionPrompt; - const promptLineCount = optionPrompt.split(/\r?\n/).length; - if (promptLineCount > 3) { - console.log(chalk.dim(' 💡 Tip: Use --file option to avoid shell escaping issues with multi-line prompts')); - console.log(chalk.dim(' Example: ccw cli -f prompt.txt --tool gemini')); - } } else { // Fall back to positional argument finalPrompt = positionalPrompt; diff --git a/ccw/src/config/cli-settings-manager.ts b/ccw/src/config/cli-settings-manager.ts new file mode 100644 index 00000000..69ea3437 --- /dev/null +++ b/ccw/src/config/cli-settings-manager.ts @@ -0,0 +1,359 @@ +/** + * CLI Settings File Manager + * Manages Claude CLI settings files for endpoint configuration + * + * Storage: ~/.ccw/cli-settings/{endpoint-id}.json + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { getCCWHome, ensureStorageDir } from './storage-paths.js'; +import { + ClaudeCliSettings, + EndpointSettings, + SettingsListResponse, + SettingsOperationResult, + SaveEndpointRequest, + validateSettings, + createDefaultSettings +} from '../types/cli-settings.js'; + +/** + * Get CLI settings directory path + */ +export function getCliSettingsDir(): string { + return join(getCCWHome(), 'cli-settings'); +} + +/** + * Get settings file path for an endpoint + */ +export function getSettingsFilePath(endpointId: string): string { + return join(getCliSettingsDir(), `${endpointId}.json`); +} + +/** + * Get index file path (stores endpoint metadata) + */ +function getIndexFilePath(): string { + return join(getCliSettingsDir(), '_index.json'); +} + +/** + * Ensure settings directory exists + */ +export function ensureSettingsDir(): void { + ensureStorageDir(getCliSettingsDir()); +} + +/** + * Load endpoint index (metadata only, not settings content) + */ +function loadIndex(): Map> { + const indexPath = getIndexFilePath(); + if (!existsSync(indexPath)) { + return new Map(); + } + + try { + const data = JSON.parse(readFileSync(indexPath, 'utf-8')); + return new Map(Object.entries(data)); + } catch { + return new Map(); + } +} + +/** + * Save endpoint index + */ +function saveIndex(index: Map>): void { + ensureSettingsDir(); + const indexPath = getIndexFilePath(); + const data = Object.fromEntries(index); + writeFileSync(indexPath, JSON.stringify(data, null, 2), 'utf-8'); +} + +/** + * Generate unique endpoint ID + */ +function generateEndpointId(): string { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + return `ep-${timestamp}-${random}`; +} + +/** + * Save endpoint settings to file + */ +export function saveEndpointSettings(request: SaveEndpointRequest): SettingsOperationResult { + try { + ensureSettingsDir(); + + const now = new Date().toISOString(); + const index = loadIndex(); + + // Determine endpoint ID + const endpointId = request.id || generateEndpointId(); + + // Check if updating existing or creating new + const existing = index.get(endpointId); + + // Create endpoint metadata + const metadata: Omit = { + id: endpointId, + name: request.name, + description: request.description, + enabled: request.enabled ?? true, + createdAt: existing?.createdAt || now, + updatedAt: now + }; + + // Save settings file + const settingsPath = getSettingsFilePath(endpointId); + writeFileSync(settingsPath, JSON.stringify(request.settings, null, 2), 'utf-8'); + + // Update index + index.set(endpointId, metadata); + saveIndex(index); + + // Return full endpoint settings + const endpoint: EndpointSettings = { + ...metadata, + settings: request.settings + }; + + return { + success: true, + message: existing ? 'Endpoint updated' : 'Endpoint created', + endpoint, + filePath: settingsPath + }; + } catch (error) { + return { + success: false, + message: `Failed to save endpoint settings: ${error instanceof Error ? error.message : String(error)}` + }; + } +} + +/** + * Load endpoint settings from file + */ +export function loadEndpointSettings(endpointId: string): EndpointSettings | null { + try { + const index = loadIndex(); + const metadata = index.get(endpointId); + + if (!metadata) { + return null; + } + + const settingsPath = getSettingsFilePath(endpointId); + if (!existsSync(settingsPath)) { + return null; + } + + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + + if (!validateSettings(settings)) { + console.error(`[CliSettings] Invalid settings format for ${endpointId}`); + return null; + } + + return { + ...metadata, + settings + }; + } catch (e) { + console.error(`[CliSettings] Failed to load settings for ${endpointId}:`, e); + return null; + } +} + +/** + * Delete endpoint settings + */ +export function deleteEndpointSettings(endpointId: string): SettingsOperationResult { + const index = loadIndex(); + + if (!index.has(endpointId)) { + return { + success: false, + message: 'Endpoint not found' + }; + } + + const settingsPath = getSettingsFilePath(endpointId); + + try { + // Step 1: Delete file first + if (existsSync(settingsPath)) { + unlinkSync(settingsPath); + } + + // Step 2: Only update index after successful file deletion + index.delete(endpointId); + saveIndex(index); + + return { + success: true, + message: 'Endpoint deleted' + }; + } catch (error) { + // If deletion fails, index remains unchanged for consistency + return { + success: false, + message: `Failed to delete endpoint file: ${error instanceof Error ? error.message : String(error)}` + }; + } +} + +/** + * List all endpoint settings + */ +export function listAllSettings(): SettingsListResponse { + try { + const index = loadIndex(); + const endpoints: EndpointSettings[] = []; + + for (const [endpointId, metadata] of index) { + const settingsPath = getSettingsFilePath(endpointId); + + if (!existsSync(settingsPath)) { + continue; + } + + try { + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + + if (validateSettings(settings)) { + endpoints.push({ + ...metadata, + settings + }); + } + } catch (e) { + // Skip invalid settings files, but log the error for debugging + console.error(`[CliSettings] Failed to load or parse settings for ${endpointId}:`, e); + } + } + + return { + endpoints, + total: endpoints.length + }; + } catch (e) { + console.error('[CliSettings] Failed to list settings:', e); + return { + endpoints: [], + total: 0 + }; + } +} + +/** + * Toggle endpoint enabled status + */ +export function toggleEndpointEnabled(endpointId: string, enabled: boolean): SettingsOperationResult { + try { + const index = loadIndex(); + const metadata = index.get(endpointId); + + if (!metadata) { + return { + success: false, + message: 'Endpoint not found' + }; + } + + metadata.enabled = enabled; + metadata.updatedAt = new Date().toISOString(); + index.set(endpointId, metadata); + saveIndex(index); + + // Load full settings for response + const endpoint = loadEndpointSettings(endpointId); + + return { + success: true, + message: enabled ? 'Endpoint enabled' : 'Endpoint disabled', + endpoint: endpoint || undefined + }; + } catch (error) { + return { + success: false, + message: `Failed to toggle endpoint: ${error instanceof Error ? error.message : String(error)}` + }; + } +} + +/** + * Get settings file path for CLI execution + * Returns null if endpoint not found or disabled + */ +export function getExecutableSettingsPath(endpointId: string): string | null { + const endpoint = loadEndpointSettings(endpointId); + + if (!endpoint || !endpoint.enabled) { + return null; + } + + const settingsPath = getSettingsFilePath(endpointId); + return existsSync(settingsPath) ? settingsPath : null; +} + +/** + * Create settings from LiteLLM provider configuration + */ +export function createSettingsFromProvider(provider: { + apiKey?: string; + apiBase?: string; + name?: string; +}, options?: { + model?: string; + includeCoAuthoredBy?: boolean; +}): ClaudeCliSettings { + const settings = createDefaultSettings(); + + // Map provider credentials to env + if (provider.apiKey) { + settings.env.ANTHROPIC_AUTH_TOKEN = provider.apiKey; + } + if (provider.apiBase) { + settings.env.ANTHROPIC_BASE_URL = provider.apiBase; + } + + // Apply options + if (options?.model) { + settings.model = options.model; + } + if (options?.includeCoAuthoredBy !== undefined) { + settings.includeCoAuthoredBy = options.includeCoAuthoredBy; + } + + return settings; +} + +/** + * Validate and sanitize endpoint ID + */ +export function sanitizeEndpointId(id: string): string { + // Remove special characters, keep alphanumeric and hyphens + return id.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); +} + +/** + * Check if endpoint ID exists + */ +export function endpointExists(endpointId: string): boolean { + const index = loadIndex(); + return index.has(endpointId); +} + +/** + * Get enabled endpoints only + */ +export function getEnabledEndpoints(): EndpointSettings[] { + const { endpoints } = listAllSettings(); + return endpoints.filter(ep => ep.enabled); +} diff --git a/ccw/src/core/routes/cli-settings-routes.ts b/ccw/src/core/routes/cli-settings-routes.ts new file mode 100644 index 00000000..119994e7 --- /dev/null +++ b/ccw/src/core/routes/cli-settings-routes.ts @@ -0,0 +1,232 @@ +/** + * CLI Settings Routes Module + * Handles Claude CLI settings file management API endpoints + */ + +import type { RouteContext } from './types.js'; +import { + saveEndpointSettings, + loadEndpointSettings, + deleteEndpointSettings, + listAllSettings, + toggleEndpointEnabled, + getSettingsFilePath, + ensureSettingsDir, + sanitizeEndpointId +} from '../../config/cli-settings-manager.js'; +import type { SaveEndpointRequest } from '../../types/cli-settings.js'; +import { validateSettings } from '../../types/cli-settings.js'; + +/** + * Handle CLI Settings routes + * @returns true if route was handled, false otherwise + */ +export async function handleCliSettingsRoutes(ctx: RouteContext): Promise { + const { pathname, req, res, handlePostRequest, broadcastToClients } = ctx; + + // Ensure settings directory exists + ensureSettingsDir(); + + // ========== LIST ALL SETTINGS ========== + // GET /api/cli/settings + if (pathname === '/api/cli/settings' && req.method === 'GET') { + try { + const result = listAllSettings(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // ========== CREATE/UPDATE SETTINGS ========== + // POST /api/cli/settings + if (pathname === '/api/cli/settings' && req.method === 'POST') { + handlePostRequest(req, res, async (body: unknown) => { + try { + const request = body as SaveEndpointRequest; + + // Validate required fields + if (!request.name) { + return { error: 'name is required', status: 400 }; + } + if (!request.settings || !request.settings.env) { + return { error: 'settings.env is required', status: 400 }; + } + // Deep validation of settings object + if (!validateSettings(request.settings)) { + return { error: 'Invalid settings object format', status: 400 }; + } + + const result = saveEndpointSettings(request); + + if (result.success) { + // Broadcast settings created/updated event + broadcastToClients({ + type: 'CLI_SETTINGS_UPDATED', + payload: { + endpoint: result.endpoint, + filePath: result.filePath, + timestamp: new Date().toISOString() + } + }); + return result; + } else { + return { error: result.message, status: 500 }; + } + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // ========== GET SINGLE SETTINGS ========== + // GET /api/cli/settings/:id + const getMatch = pathname.match(/^\/api\/cli\/settings\/([^/]+)$/); + if (getMatch && req.method === 'GET') { + const endpointId = sanitizeEndpointId(getMatch[1]); + try { + const endpoint = loadEndpointSettings(endpointId); + + if (!endpoint) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Endpoint not found' })); + return true; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + endpoint, + filePath: getSettingsFilePath(endpointId) + })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // ========== UPDATE SETTINGS ========== + // PUT /api/cli/settings/:id + const putMatch = pathname.match(/^\/api\/cli\/settings\/([^/]+)$/); + if (putMatch && req.method === 'PUT') { + const endpointId = sanitizeEndpointId(putMatch[1]); + handlePostRequest(req, res, async (body: unknown) => { + try { + const request = body as Partial; + + // Check if just toggling enabled status + if (Object.keys(request).length === 1 && 'enabled' in request) { + const result = toggleEndpointEnabled(endpointId, request.enabled as boolean); + + if (result.success) { + broadcastToClients({ + type: 'CLI_SETTINGS_TOGGLED', + payload: { + endpointId, + enabled: request.enabled, + timestamp: new Date().toISOString() + } + }); + } + return result; + } + + // Full update + const existing = loadEndpointSettings(endpointId); + if (!existing) { + return { error: 'Endpoint not found', status: 404 }; + } + + const updateRequest: SaveEndpointRequest = { + id: endpointId, + name: request.name || existing.name, + description: request.description ?? existing.description, + settings: request.settings || existing.settings, + enabled: request.enabled ?? existing.enabled + }; + + const result = saveEndpointSettings(updateRequest); + + if (result.success) { + broadcastToClients({ + type: 'CLI_SETTINGS_UPDATED', + payload: { + endpoint: result.endpoint, + filePath: result.filePath, + timestamp: new Date().toISOString() + } + }); + } + + return result; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // ========== DELETE SETTINGS ========== + // DELETE /api/cli/settings/:id + const deleteMatch = pathname.match(/^\/api\/cli\/settings\/([^/]+)$/); + if (deleteMatch && req.method === 'DELETE') { + const endpointId = sanitizeEndpointId(deleteMatch[1]); + try { + const result = deleteEndpointSettings(endpointId); + + if (result.success) { + broadcastToClients({ + type: 'CLI_SETTINGS_DELETED', + payload: { + endpointId, + timestamp: new Date().toISOString() + } + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // ========== GET SETTINGS FILE PATH ========== + // GET /api/cli/settings/:id/path + const pathMatch = pathname.match(/^\/api\/cli\/settings\/([^/]+)\/path$/); + if (pathMatch && req.method === 'GET') { + const endpointId = sanitizeEndpointId(pathMatch[1]); + try { + const endpoint = loadEndpointSettings(endpointId); + + if (!endpoint) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Endpoint not found' })); + return true; + } + + const filePath = getSettingsFilePath(endpointId); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + endpointId, + filePath, + enabled: endpoint.enabled + })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + return false; +} diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index cecfc4b8..2580af8a 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -7,6 +7,7 @@ import { resolvePath, getRecentPaths, normalizePathForDisplay } from '../utils/p // Import route handlers import { handleStatusRoutes } from './routes/status-routes.js'; import { handleCliRoutes } from './routes/cli-routes.js'; +import { handleCliSettingsRoutes } from './routes/cli-settings-routes.js'; import { handleMemoryRoutes } from './routes/memory-routes.js'; import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js'; import { handleMcpRoutes } from './routes/mcp-routes.js'; @@ -441,6 +442,8 @@ export async function startServer(options: ServerOptions = {}): Promise' + ' ' + t('apiSettings.endpoints') + '' + + '' + '' + @@ -1035,6 +1131,15 @@ async function renderApiSettings() { sidebarContentHtml = '
' + '

' + t('apiSettings.cacheTabHint') + '

' + '
'; + } else if (activeSidebarTab === 'cli-settings') { + // Load CLI settings first if not already loaded + if (!cliSettingsData) { + await loadCliSettings(); + } + sidebarContentHtml = '
'; + addButtonHtml = ''; } // Build split layout @@ -1074,6 +1179,16 @@ async function renderApiSettings() { renderEmbeddingPoolMainPanel(); } else if (activeSidebarTab === 'cache') { renderCacheMainPanel(); + } else if (activeSidebarTab === 'cli-settings') { + renderCliSettingsList(); + // Auto-select first settings if exists + if (!selectedCliSettingsId && cliSettingsData && cliSettingsData.endpoints && cliSettingsData.endpoints.length > 0) { + selectCliSettings(cliSettingsData.endpoints[0].id); + } else if (selectedCliSettingsId) { + renderCliSettingsDetail(selectedCliSettingsId); + } else { + renderCliSettingsEmptyState(); + } } // Check and render ccw-litellm status @@ -3479,6 +3594,302 @@ window.renderCcwLitellmStatusCard = renderCcwLitellmStatusCard; window.installCcwLitellm = installCcwLitellm; window.uninstallCcwLitellm = uninstallCcwLitellm; +// ========== CLI Settings Functions ========== + +/** + * Render CLI Settings list in sidebar + */ +function renderCliSettingsList() { + var container = document.getElementById('cli-settings-list'); + if (!container) return; + + var endpoints = (cliSettingsData && cliSettingsData.endpoints) ? cliSettingsData.endpoints : []; + + if (endpoints.length === 0) { + container.innerHTML = '
' + + '

' + t('apiSettings.noCliSettings') + '

' + + '
'; + return; + } + + var html = ''; + endpoints.forEach(function(endpoint) { + var isSelected = endpoint.id === selectedCliSettingsId; + html += '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + escapeHtml(endpoint.name) + '
' + + '
' + (endpoint.settings.model || 'sonnet') + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
'; + }); + + container.innerHTML = html; + if (window.lucide) lucide.createIcons(); +} + +/** + * Select CLI Settings endpoint + */ +function selectCliSettings(endpointId) { + selectedCliSettingsId = endpointId; + renderCliSettingsList(); + renderCliSettingsDetail(endpointId); +} + +/** + * Render CLI Settings detail panel + */ +function renderCliSettingsDetail(endpointId) { + var container = document.getElementById('provider-detail-panel'); + if (!container) return; + + var endpoint = null; + if (cliSettingsData && cliSettingsData.endpoints) { + endpoint = cliSettingsData.endpoints.find(function(e) { return e.id === endpointId; }); + } + + if (!endpoint) { + renderCliSettingsEmptyState(); + return; + } + + var settings = endpoint.settings || {}; + var env = settings.env || {}; + + container.innerHTML = + '
' + + '

' + escapeHtml(endpoint.name) + '

' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '

' + t('apiSettings.basicInfo') + '

' + + '
' + + '
' + + '' + + '' + escapeHtml(endpoint.id) + '' + + '
' + + '
' + + '' + + '' + escapeHtml(settings.model || 'sonnet') + '' + + '
' + + '
' + + '' + + '' + + (endpoint.enabled ? t('common.enabled') : t('common.disabled')) + + '' + + '
' + + '
' + + '
' + + '
' + + '

' + t('apiSettings.envSettings') + '

' + + '
' + + '
' + + '' + + '' + (env.ANTHROPIC_AUTH_TOKEN ? '••••••••' + env.ANTHROPIC_AUTH_TOKEN.slice(-8) : '-') + '' + + '
' + + '
' + + '' + + '' + escapeHtml(env.ANTHROPIC_BASE_URL || '-') + '' + + '
' + + '
' + + '
' + + '
' + + '

' + t('apiSettings.settingsFilePath') + '

' + + '
' + + 'claude -p --settings ~/.ccw/cli-settings/' + endpoint.id + '.json' + + '
' + + '
' + + '
'; + + if (window.lucide) lucide.createIcons(); +} + +/** + * Render CLI Settings empty state + */ +function renderCliSettingsEmptyState() { + var container = document.getElementById('provider-detail-panel'); + if (!container) return; + + container.innerHTML = + '
' + + '' + + '

' + t('apiSettings.noCliSettingsSelected') + '

' + + '

' + t('apiSettings.cliSettingsHint') + '

' + + '
'; + + if (window.lucide) lucide.createIcons(); +} + +/** + * Show Add CLI Settings Modal + */ +function showAddCliSettingsModal(existingEndpoint) { + var isEdit = !!existingEndpoint; + var settings = existingEndpoint ? existingEndpoint.settings : { env: {}, model: 'sonnet' }; + var env = settings.env || {}; + + var modalHtml = + ''; + + // Append modal to body + var modalsContainer = document.getElementById('modals'); + if (!modalsContainer) { + modalsContainer = document.createElement('div'); + modalsContainer.id = 'modals'; + document.body.appendChild(modalsContainer); + } + modalsContainer.innerHTML = modalHtml; +} + +/** + * Edit CLI Settings + */ +function editCliSettings(endpointId) { + var endpoint = null; + if (cliSettingsData && cliSettingsData.endpoints) { + endpoint = cliSettingsData.endpoints.find(function(e) { return e.id === endpointId; }); + } + if (endpoint) { + showAddCliSettingsModal(endpoint); + } +} + +/** + * Close CLI Settings Modal + */ +function closeCliSettingsModal() { + var modalsContainer = document.getElementById('modals'); + if (modalsContainer) { + modalsContainer.innerHTML = ''; + } +} + +/** + * Submit CLI Settings Form + */ +async function submitCliSettings() { + var name = document.getElementById('cli-settings-name').value.trim(); + var description = document.getElementById('cli-settings-description').value.trim(); + var model = document.getElementById('cli-settings-model').value; + var token = document.getElementById('cli-settings-token').value.trim(); + var baseUrl = document.getElementById('cli-settings-base-url').value.trim(); + var enabled = document.getElementById('cli-settings-enabled').checked; + var idInput = document.getElementById('cli-settings-id'); + var id = idInput ? idInput.value : null; + + if (!name) { + showRefreshToast(t('apiSettings.nameRequired'), 'error'); + return; + } + + if (!token) { + showRefreshToast(t('apiSettings.tokenRequired'), 'error'); + return; + } + + var data = { + name: name, + description: description, + enabled: enabled, + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: token, + DISABLE_AUTOUPDATER: '1' + }, + model: model + } + }; + + if (baseUrl) { + data.settings.env.ANTHROPIC_BASE_URL = baseUrl; + } + + if (id) { + data.id = id; + } + + var result = await saveCliSettingsEndpoint(data); + if (result && result.success) { + closeCliSettingsModal(); + } +} + +// Make CLI Settings functions globally accessible +window.loadCliSettings = loadCliSettings; +window.saveCliSettingsEndpoint = saveCliSettingsEndpoint; +window.deleteCliSettingsEndpoint = deleteCliSettingsEndpoint; +window.renderCliSettingsList = renderCliSettingsList; +window.selectCliSettings = selectCliSettings; +window.renderCliSettingsDetail = renderCliSettingsDetail; +window.renderCliSettingsEmptyState = renderCliSettingsEmptyState; +window.showAddCliSettingsModal = showAddCliSettingsModal; +window.editCliSettings = editCliSettings; +window.closeCliSettingsModal = closeCliSettingsModal; +window.submitCliSettings = submitCliSettings; + // ========== Utility Functions ========== diff --git a/ccw/src/tools/cli-config-manager.ts b/ccw/src/tools/cli-config-manager.ts index 7dbff753..a57cb0f1 100644 --- a/ccw/src/tools/cli-config-manager.ts +++ b/ccw/src/tools/cli-config-manager.ts @@ -65,8 +65,8 @@ export const DEFAULT_CONFIG: CliConfig = { }, opencode: { enabled: true, - primaryModel: 'anthropic/claude-sonnet-4-20250514', - secondaryModel: 'anthropic/claude-haiku' + primaryModel: '', // Empty = use opencode's default config + secondaryModel: '' } } }; diff --git a/ccw/src/tools/cli-executor-core.ts b/ccw/src/tools/cli-executor-core.ts index 48db5c16..3e274188 100644 --- a/ccw/src/tools/cli-executor-core.ts +++ b/ccw/src/tools/cli-executor-core.ts @@ -308,7 +308,9 @@ async function executeCliTool( tool, resumeIds, customId, - forcePromptConcat: noNative, + // Force prompt-concat if noNative flag is set OR if tool doesn't support native resume + // (e.g., codex resume requires TTY which spawn() doesn't provide) + forcePromptConcat: noNative || !supportsNativeResume(tool), getNativeSessionId: (ccwId) => store.getNativeSessionId(ccwId), getConversation: (ccwId) => loadConversation(workingDir, ccwId), getConversationTool: (ccwId) => { diff --git a/ccw/src/tools/cli-executor-utils.ts b/ccw/src/tools/cli-executor-utils.ts index cca63cb7..a36b8cd6 100644 --- a/ccw/src/tools/cli-executor-utils.ts +++ b/ccw/src/tools/cli-executor-utils.ts @@ -157,8 +157,10 @@ export function buildCommand(params: { dir?: string; include?: string; nativeResume?: NativeResumeConfig; + /** Claude CLI settings file path (for --settings parameter) */ + settingsFile?: string; }): { command: string; args: string[]; useStdin: boolean } { - const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume } = params; + const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile } = params; debugLog('BUILD_CMD', `Building command for tool: ${tool}`, { mode, @@ -238,7 +240,10 @@ export function buildCommand(params: { args.push('--add-dir', addDir); } } - args.push('-'); + // codex resume uses positional prompt argument, not stdin + // Format: codex resume [prompt] + useStdin = false; + args.push(prompt); } else { args.push('exec'); if (mode === 'write' || mode === 'auto') { @@ -262,6 +267,10 @@ export function buildCommand(params: { case 'claude': // Claude Code: claude -p "prompt" for non-interactive mode args.push('-p'); // Print mode (non-interactive) + // Settings file: claude --settings + if (settingsFile) { + args.push('--settings', settingsFile); + } // Native resume: claude --resume or --continue if (nativeResume?.enabled) { if (nativeResume.isLatest) { @@ -291,8 +300,10 @@ export function buildCommand(params: { break; case 'opencode': - // OpenCode: opencode run "prompt" for non-interactive mode + // OpenCode: opencode run [message..] for non-interactive mode // https://opencode.ai/docs/cli/ + // Prompt is passed as positional arguments (NOT stdin) + useStdin = false; args.push('run'); // Native resume: opencode run --continue or --session if (nativeResume?.enabled) { @@ -306,14 +317,11 @@ export function buildCommand(params: { if (model) { args.push('--model', model); } - // Write mode: Use full-auto permission via environment or default permissive mode - // OpenCode uses OPENCODE_PERMISSION env var for permission control - // For now, we rely on project-level configuration // Output format for parsing args.push('--format', 'default'); - // Prompt is passed as positional argument after 'run' - // Use stdin for prompt to avoid shell escaping issues - useStdin = true; + // Add prompt as positional argument at the end + // OpenCode expects: opencode run [options] [message..] + args.push(prompt); break; default: diff --git a/ccw/src/tools/native-session-discovery.ts b/ccw/src/tools/native-session-discovery.ts index 6701bdeb..39a60504 100644 --- a/ccw/src/tools/native-session-discovery.ts +++ b/ccw/src/tools/native-session-discovery.ts @@ -681,82 +681,124 @@ class ClaudeSessionDiscoverer extends SessionDiscoverer { /** * OpenCode Session Discoverer - * Path: ~/.config/opencode/sessions/ or ~/.opencode/sessions/ (fallback) - * OpenCode stores sessions with UUID-based session IDs - * https://opencode.ai/docs/cli/ + * Storage path: ~/.local/share/opencode/storage/ (all platforms) + * Structure: + * session//.json - Session metadata + * message//.json - Message content + * part//.json - Message parts + * project/.json - Project metadata + * https://opencode.ai/docs/config/ */ class OpenCodeSessionDiscoverer extends SessionDiscoverer { tool = 'opencode'; - // Primary: XDG config path, fallback to .opencode in home + // Storage base path: ~/.local/share/opencode/storage basePath = join( - process.env.OPENCODE_CONFIG_DIR || - process.env.XDG_CONFIG_HOME || - join(getHomePath(), '.config'), - 'opencode' + process.env.USERPROFILE || getHomePath(), + '.local', + 'share', + 'opencode', + 'storage' ); - fallbackBasePath = join(getHomePath(), '.opencode'); - private getSessionsDir(): string | null { - // Check primary path first - const primarySessionsDir = join(this.basePath, 'sessions'); - if (existsSync(primarySessionsDir)) { - return primarySessionsDir; - } - // Fallback to ~/.opencode/sessions - const fallbackSessionsDir = join(this.fallbackBasePath, 'sessions'); - if (existsSync(fallbackSessionsDir)) { - return fallbackSessionsDir; - } - return null; - } - - getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] { - const { limit, afterTimestamp } = options; - const sessions: NativeSession[] = []; - - const sessionsDir = this.getSessionsDir(); - if (!sessionsDir) return []; + private getProjectHash(workingDir: string): string | null { + // OpenCode uses SHA1 hash of the project directory path + const sessionDir = join(this.basePath, 'session'); + if (!existsSync(sessionDir)) return null; try { - // OpenCode stores sessions as JSON/JSONL files with UUID names - const sessionFiles = readdirSync(sessionsDir) - .filter(f => f.endsWith('.json') || f.endsWith('.jsonl')) - .map(f => ({ - name: f, - path: join(sessionsDir, f), - stat: statSync(join(sessionsDir, f)) - })) - .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs); + const projectHashes = readdirSync(sessionDir).filter(d => { + const fullPath = join(sessionDir, d); + return statSync(fullPath).isDirectory(); + }); - for (const file of sessionFiles) { - if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue; + if (projectHashes.length === 0) return null; - try { - // Try to extract session ID from filename (UUID pattern) - const uuidMatch = file.name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); - let sessionId: string; - - if (uuidMatch) { - sessionId = uuidMatch[1]; - } else { - // Try reading first line for session metadata - const firstLine = readFileSync(file.path, 'utf8').split('\n')[0]; - const meta = JSON.parse(firstLine); - sessionId = meta.id || meta.session_id || basename(file.name, '.json').replace('.jsonl', ''); + // If workingDir provided, try to find matching project + if (workingDir) { + const normalizedWorkDir = resolve(workingDir); + // Check project files for directory match + const projectDir = join(this.basePath, 'project'); + if (existsSync(projectDir)) { + for (const hash of projectHashes) { + const projectFile = join(projectDir, `${hash}.json`); + if (existsSync(projectFile)) { + try { + const projectData = JSON.parse(readFileSync(projectFile, 'utf8')); + // Normalize path comparison for Windows + const projectPath = projectData.directory?.replace(/\\/g, '/').toLowerCase(); + const targetPath = normalizedWorkDir.replace(/\\/g, '/').toLowerCase(); + if (projectPath === targetPath) { + return hash; + } + } catch { + // Skip invalid project files + } + } } - - sessions.push({ - sessionId, - tool: this.tool, - filePath: file.path, - createdAt: file.stat.birthtime, - updatedAt: file.stat.mtime - }); - } catch { - // Skip invalid files } } + // Return first available project hash if no match + return projectHashes[0]; + } catch { + return null; + } + } + + getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] { + const { workingDir, limit, afterTimestamp } = options; + const sessions: NativeSession[] = []; + + const sessionDir = join(this.basePath, 'session'); + if (!existsSync(sessionDir)) return []; + + try { + // Get all project directories or specific one + let projectHashes: string[]; + if (workingDir) { + const hash = this.getProjectHash(workingDir); + projectHashes = hash ? [hash] : []; + } else { + projectHashes = readdirSync(sessionDir).filter(d => { + const fullPath = join(sessionDir, d); + return statSync(fullPath).isDirectory(); + }); + } + + for (const projectHash of projectHashes) { + const projectSessionDir = join(sessionDir, projectHash); + if (!existsSync(projectSessionDir)) continue; + + // Get all session files + const sessionFiles = readdirSync(projectSessionDir) + .filter(f => f.endsWith('.json')) + .map(f => ({ + name: f, + path: join(projectSessionDir, f), + stat: statSync(join(projectSessionDir, f)) + })) + .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs); + + for (const file of sessionFiles) { + if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue; + + try { + const sessionData = JSON.parse(readFileSync(file.path, 'utf8')); + sessions.push({ + sessionId: sessionData.id || basename(file.name, '.json'), + tool: this.tool, + filePath: file.path, + projectHash, + createdAt: new Date(sessionData.time?.created || file.stat.birthtime), + updatedAt: new Date(sessionData.time?.updated || file.stat.mtime) + }); + } catch { + // Skip invalid files + } + } + } + + // Sort by updatedAt descending sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); return limit ? sessions.slice(0, limit) : sessions; } catch { @@ -770,42 +812,61 @@ class OpenCodeSessionDiscoverer extends SessionDiscoverer { } /** - * Extract first user message from OpenCode session file - * Format may vary - try common patterns + * Extract first user message from OpenCode session + * Messages are stored in: message//.json + * Format: { id, sessionID, role, time } + * Content is in parts: part//.json */ extractFirstUserMessage(filePath: string): string | null { try { - const content = readFileSync(filePath, 'utf8'); + // filePath is the session JSON file + const sessionData = JSON.parse(readFileSync(filePath, 'utf8')); + const sessionId = sessionData.id; + if (!sessionId) return null; - // Check if JSON or JSONL - if (filePath.endsWith('.jsonl')) { - const lines = content.split('\n').filter(l => l.trim()); - for (const line of lines) { - try { - const entry = JSON.parse(line); - // Try common patterns for user message - if (entry.role === 'user' && entry.content) { - return entry.content; + // Find messages for this session + const messageDir = join(this.basePath, 'message', sessionId); + if (!existsSync(messageDir)) return null; + + // Get message files sorted by time + const messageFiles = readdirSync(messageDir) + .filter(f => f.endsWith('.json')) + .map(f => ({ + name: f, + path: join(messageDir, f) + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const msgFile of messageFiles) { + try { + const msgData = JSON.parse(readFileSync(msgFile.path, 'utf8')); + if (msgData.role === 'user') { + // Get content from parts + const partDir = join(this.basePath, 'part', msgData.id); + if (existsSync(partDir)) { + const partFiles = readdirSync(partDir) + .filter(f => f.endsWith('.json')) + .sort(); + + for (const partFile of partFiles) { + try { + const partData = JSON.parse(readFileSync(join(partDir, partFile), 'utf8')); + if (partData.type === 'text' && partData.text) { + return partData.text; + } + } catch { + // Skip invalid parts + } + } } - if (entry.type === 'user' && entry.message) { - return typeof entry.message === 'string' ? entry.message : entry.message.content; - } - if (entry.type === 'user_message' && entry.content) { - return entry.content; - } - } catch { /* skip invalid lines */ } - } - } else { - // JSON format - look for messages array - const data = JSON.parse(content); - if (data.messages && Array.isArray(data.messages)) { - const userMsg = data.messages.find((m: { role?: string; type?: string }) => - m.role === 'user' || m.type === 'user' - ); - return userMsg?.content || null; + // Fallback to title if available + return msgData.summary?.title || sessionData.title || null; + } + } catch { + // Skip invalid messages } } - return null; + return sessionData.title || null; } catch { return null; } @@ -884,8 +945,14 @@ export function getNativeSessions( /** * Check if a tool supports native resume + * Note: codex is excluded because `codex resume` requires a TTY (terminal) + * which doesn't work in spawn() context. Codex uses prompt-concat mode instead. */ export function supportsNativeResume(tool: string): boolean { + // codex resume requires TTY - use prompt-concat mode instead + if (tool === 'codex') { + return false; + } return tool in discoverers; } diff --git a/ccw/src/types/cli-settings.ts b/ccw/src/types/cli-settings.ts new file mode 100644 index 00000000..7399d4c6 --- /dev/null +++ b/ccw/src/types/cli-settings.ts @@ -0,0 +1,137 @@ +/** + * CLI Settings Type Definitions + * Supports Claude CLI --settings parameter format + */ + +/** + * Claude CLI Settings 文件格式 + * 对应 `claude --settings ` 参数 + */ +export interface ClaudeCliSettings { + /** 环境变量配置 */ + env: { + /** Anthropic API Token */ + ANTHROPIC_AUTH_TOKEN?: string; + /** Anthropic API Base URL */ + ANTHROPIC_BASE_URL?: string; + /** 禁用自动更新 */ + DISABLE_AUTOUPDATER?: string; + /** 其他自定义环境变量 */ + [key: string]: string | undefined; + }; + /** 模型选择 */ + model?: 'opus' | 'sonnet' | 'haiku' | string; + /** 是否包含 co-authored-by */ + includeCoAuthoredBy?: boolean; +} + +/** + * 端点 Settings 配置(带元数据) + */ +export interface EndpointSettings { + /** 端点唯一标识 */ + id: string; + /** 端点显示名称 */ + name: string; + /** 端点描述 */ + description?: string; + /** Claude CLI Settings */ + settings: ClaudeCliSettings; + /** 是否启用 */ + enabled: boolean; + /** 创建时间 */ + createdAt: string; + /** 更新时间 */ + updatedAt: string; +} + +/** + * Settings 列表响应 + */ +export interface SettingsListResponse { + endpoints: EndpointSettings[]; + total: number; +} + +/** + * Settings 操作结果 + */ +export interface SettingsOperationResult { + success: boolean; + message?: string; + endpoint?: EndpointSettings; + filePath?: string; +} + +/** + * 创建/更新端点请求 + */ +export interface SaveEndpointRequest { + id?: string; + name: string; + description?: string; + settings: ClaudeCliSettings; + enabled?: boolean; +} + +/** + * 从 LiteLLM Provider 映射到 Claude CLI env + */ +export function mapProviderToClaudeEnv(provider: { + apiKey?: string; + apiBase?: string; +}): ClaudeCliSettings['env'] { + const env: ClaudeCliSettings['env'] = {}; + + if (provider.apiKey) { + env.ANTHROPIC_AUTH_TOKEN = provider.apiKey; + } + if (provider.apiBase) { + env.ANTHROPIC_BASE_URL = provider.apiBase; + } + // 默认禁用自动更新 + env.DISABLE_AUTOUPDATER = '1'; + + return env; +} + +/** + * 创建默认 Settings + */ +export function createDefaultSettings(): ClaudeCliSettings { + return { + env: { + DISABLE_AUTOUPDATER: '1' + }, + model: 'sonnet', + includeCoAuthoredBy: false + }; +} + +/** + * 验证 Settings 格式 + */ +export function validateSettings(settings: unknown): settings is ClaudeCliSettings { + if (!settings || typeof settings !== 'object') { + return false; + } + + const s = settings as Record; + + // env 必须存在且为对象 + if (!s.env || typeof s.env !== 'object') { + return false; + } + + // model 可选,但如果存在必须是字符串 + if (s.model !== undefined && typeof s.model !== 'string') { + return false; + } + + // includeCoAuthoredBy 可选,但如果存在必须是布尔值 + if (s.includeCoAuthoredBy !== undefined && typeof s.includeCoAuthoredBy !== 'boolean') { + return false; + } + + return true; +}