diff --git a/ccw/src/config/cli-settings-manager.ts b/ccw/src/config/cli-settings-manager.ts index 69ea3437..29e84885 100644 --- a/ccw/src/config/cli-settings-manager.ts +++ b/ccw/src/config/cli-settings-manager.ts @@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from 'fs'; import { join } from 'path'; +import * as os from 'os'; import { getCCWHome, ensureStorageDir } from './storage-paths.js'; import { ClaudeCliSettings, @@ -17,6 +18,10 @@ import { validateSettings, createDefaultSettings } from '../types/cli-settings.js'; +import { + addClaudeCustomEndpoint, + removeClaudeCustomEndpoint +} from '../tools/claude-cli-tools.js'; /** * Get CLI settings directory path @@ -116,6 +121,23 @@ export function saveEndpointSettings(request: SaveEndpointRequest): SettingsOper index.set(endpointId, metadata); saveIndex(index); + // Sync with cli-tools.json for ccw cli --tool integration + // API endpoints are added as tools with type: 'api-endpoint' + // Usage: ccw cli -p "..." --tool custom --model --mode analysis + try { + const projectDir = os.homedir(); // Use home dir as base for global config + addClaudeCustomEndpoint(projectDir, { + id: endpointId, + name: request.name, + enabled: request.enabled ?? true + // No cli-wrapper tag -> registers as type: 'api-endpoint' + }); + console.log(`[CliSettings] Synced endpoint ${endpointId} to cli-tools.json tools`); + } catch (syncError) { + console.warn(`[CliSettings] Failed to sync with cli-tools.json: ${syncError}`); + // Non-fatal: continue even if sync fails + } + // Return full endpoint settings const endpoint: EndpointSettings = { ...metadata, @@ -195,6 +217,16 @@ export function deleteEndpointSettings(endpointId: string): SettingsOperationRes index.delete(endpointId); saveIndex(index); + // Step 3: Remove from cli-tools.json tools (api-endpoint type) + try { + const projectDir = os.homedir(); + removeClaudeCustomEndpoint(projectDir, endpointId); + console.log(`[CliSettings] Removed endpoint ${endpointId} from cli-tools.json tools`); + } catch (syncError) { + console.warn(`[CliSettings] Failed to remove from cli-tools.json: ${syncError}`); + // Non-fatal: continue even if sync fails + } + return { success: true, message: 'Endpoint deleted' @@ -271,6 +303,20 @@ export function toggleEndpointEnabled(endpointId: string, enabled: boolean): Set index.set(endpointId, metadata); saveIndex(index); + // Sync enabled status with cli-tools.json tools (api-endpoint type) + try { + const projectDir = os.homedir(); + addClaudeCustomEndpoint(projectDir, { + id: endpointId, + name: metadata.name, + enabled: enabled + // No cli-wrapper tag -> updates as type: 'api-endpoint' + }); + console.log(`[CliSettings] Synced endpoint ${endpointId} enabled=${enabled} to cli-tools.json tools`); + } catch (syncError) { + console.warn(`[CliSettings] Failed to sync enabled status to cli-tools.json: ${syncError}`); + } + // Load full settings for response const endpoint = loadEndpointSettings(endpointId); @@ -357,3 +403,58 @@ export function getEnabledEndpoints(): EndpointSettings[] { const { endpoints } = listAllSettings(); return endpoints.filter(ep => ep.enabled); } + +/** + * Find endpoint by name (case-insensitive) + * Useful for CLI where user types --tool doubao instead of --tool ep-xxx + */ +export function findEndpointByName(name: string): EndpointSettings | null { + const { endpoints } = listAllSettings(); + const lowerName = name.toLowerCase(); + return endpoints.find(ep => ep.name.toLowerCase() === lowerName) || null; +} + +/** + * Find endpoint by ID or name + * First tries exact ID match, then falls back to name match + */ +export function findEndpoint(idOrName: string): EndpointSettings | null { + // Try by ID first + const byId = loadEndpointSettings(idOrName); + if (byId) return byId; + + // Try by name + return findEndpointByName(idOrName); +} + +/** + * Validate endpoint name for CLI compatibility + * Name must be: lowercase, alphanumeric, hyphens allowed, no spaces or special chars + */ +export function validateEndpointName(name: string): { valid: boolean; error?: string } { + if (!name || name.trim().length === 0) { + return { valid: false, error: 'Name is required' }; + } + + // Check for valid characters: a-z, 0-9, hyphen, underscore + const validPattern = /^[a-z][a-z0-9_-]*$/; + if (!validPattern.test(name.toLowerCase())) { + return { + valid: false, + error: 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores' + }; + } + + // Check length + if (name.length > 32) { + return { valid: false, error: 'Name must be 32 characters or less' }; + } + + // Check if name conflicts with built-in tools + const builtinTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode', 'litellm']; + if (builtinTools.includes(name.toLowerCase())) { + return { valid: false, error: `Name "${name}" conflicts with a built-in tool` }; + } + + return { valid: true }; +} diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index 5f0731f0..f68f048c 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -41,8 +41,10 @@ import { updateClaudeToolEnabled, updateClaudeCacheSettings, getClaudeCliToolsInfo, - addClaudeCustomEndpoint, - removeClaudeCustomEndpoint, + addClaudeApiEndpoint, + removeClaudeApiEndpoint, + addClaudeCustomEndpoint, // @deprecated - kept for backward compatibility + removeClaudeCustomEndpoint, // @deprecated - kept for backward compatibility updateCodeIndexMcp, getCodeIndexMcp } from '../../tools/claude-cli-tools.js'; @@ -238,13 +240,21 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { } } - // API: Get all custom endpoints + // Helper: Get API endpoints from tools (type: 'api-endpoint') + const getApiEndpointsFromTools = (config: any) => { + return Object.entries(config.tools) + .filter(([_, t]: [string, any]) => t.type === 'api-endpoint') + .map(([name, t]: [string, any]) => ({ id: t.id || name, name, enabled: t.enabled })); + }; + + // API: Get all API endpoints (for --tool custom --model ) if (pathname === '/api/cli/endpoints' && req.method === 'GET') { try { // Use ensureClaudeCliTools to auto-create config if missing const config = ensureClaudeCliTools(initialPath); + const endpoints = getApiEndpointsFromTools(config); res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ endpoints: config.customEndpoints || [] })); + res.end(JSON.stringify({ endpoints })); } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: (err as Error).message })); @@ -252,7 +262,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { return true; } - // API: Add/Update custom endpoint + // API: Add/Update API endpoint if (pathname === '/api/cli/endpoints' && req.method === 'POST') { handlePostRequest(req, res, async (body: unknown) => { try { @@ -260,14 +270,14 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { if (!id || !name) { return { error: 'id and name are required', status: 400 }; } - const config = addClaudeCustomEndpoint(initialPath, { id, name, enabled: enabled !== false }); + const config = addClaudeApiEndpoint(initialPath, { id, name, enabled: enabled !== false }); broadcastToClients({ type: 'CLI_ENDPOINT_UPDATED', payload: { endpoint: { id, name, enabled }, timestamp: new Date().toISOString() } }); - return { success: true, endpoints: config.customEndpoints }; + return { success: true, endpoints: getApiEndpointsFromTools(config) }; } catch (err) { return { error: (err as Error).message, status: 500 }; } @@ -275,24 +285,36 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { return true; } - // API: Update custom endpoint enabled status + // API: Update API endpoint enabled status if (pathname.match(/^\/api\/cli\/endpoints\/[^/]+$/) && req.method === 'PUT') { const endpointId = pathname.split('/').pop() || ''; handlePostRequest(req, res, async (body: unknown) => { try { - const { enabled, name } = body as { enabled?: boolean; name?: string }; + const { enabled, name: newName } = body as { enabled?: boolean; name?: string }; const config = loadClaudeCliTools(initialPath); - const endpoint = config.customEndpoints.find(e => e.id === endpointId); - if (!endpoint) { + // Find the tool by id (api-endpoint type) + const toolEntry = Object.entries(config.tools).find( + ([_, t]: [string, any]) => t.type === 'api-endpoint' && t.id === endpointId + ); + + if (!toolEntry) { return { error: 'Endpoint not found', status: 404 }; } - if (typeof enabled === 'boolean') endpoint.enabled = enabled; - if (name) endpoint.name = name; + const [toolName, tool] = toolEntry as [string, any]; + + if (typeof enabled === 'boolean') tool.enabled = enabled; + // If name changes, we need to rename the key + if (newName && newName !== toolName) { + delete config.tools[toolName]; + config.tools[newName] = tool; + } saveClaudeCliTools(initialPath, config); + const endpoint = { id: tool.id || toolName, name: newName || toolName, enabled: tool.enabled }; + broadcastToClients({ type: 'CLI_ENDPOINT_UPDATED', payload: { endpoint, timestamp: new Date().toISOString() } @@ -306,11 +328,11 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { return true; } - // API: Delete custom endpoint + // API: Delete API endpoint if (pathname.match(/^\/api\/cli\/endpoints\/[^/]+$/) && req.method === 'DELETE') { const endpointId = pathname.split('/').pop() || ''; try { - const config = removeClaudeCustomEndpoint(initialPath, endpointId); + const config = removeClaudeApiEndpoint(initialPath, endpointId); broadcastToClients({ type: 'CLI_ENDPOINT_DELETED', @@ -318,7 +340,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { }); res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: true, endpoints: config.customEndpoints })); + res.end(JSON.stringify({ success: true, endpoints: getApiEndpointsFromTools(config) })); } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: (err as Error).message })); @@ -737,8 +759,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { const currentTools = loadClaudeCliTools(initialPath); const updatedTools = { ...currentTools, - tools: { ...currentTools.tools, ...(updates.tools.tools || {}) }, - customEndpoints: updates.tools.customEndpoints || currentTools.customEndpoints + tools: { ...currentTools.tools, ...(updates.tools.tools || {}) } }; saveClaudeCliTools(initialPath, updatedTools); } diff --git a/ccw/src/templates/dashboard-css/31-api-settings.css b/ccw/src/templates/dashboard-css/31-api-settings.css index 84d10394..fecadc7a 100644 --- a/ccw/src/templates/dashboard-css/31-api-settings.css +++ b/ccw/src/templates/dashboard-css/31-api-settings.css @@ -1272,9 +1272,100 @@ select.cli-input { letter-spacing: 0.03em; } +/* Provider Item (used in CLI Settings list) */ +.provider-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + border-radius: 0.5rem; + cursor: pointer; + transition: background 0.15s ease; + border: 1px solid transparent; +} + +.provider-item:hover { + background: hsl(var(--muted) / 0.5); +} + +.provider-item.selected { + background: hsl(var(--primary) / 0.1); + border-color: hsl(var(--primary) / 0.3); +} + +.provider-item-content { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; + flex: 1; +} + +.provider-icon { + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: hsl(var(--muted) / 0.5); + color: hsl(var(--muted-foreground)); +} + +.provider-icon i, +.provider-icon svg { + width: 1rem; + height: 1rem; +} + +.provider-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.provider-name { + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.provider-type { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.provider-status { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.provider-status .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: hsl(var(--muted-foreground)); +} + +.provider-status.enabled .status-dot { + background: hsl(142 76% 36%); +} + +.provider-status.disabled .status-dot { + background: hsl(var(--muted-foreground) / 0.5); +} + .provider-list-footer { padding: 1rem; border-top: 1px solid hsl(var(--border)); + margin-top: auto; } .btn-full { @@ -1327,6 +1418,75 @@ select.cli-input { gap: 1.5rem; } +/* Detail Section (for CLI Settings, etc.) */ +.detail-section { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + padding: 1rem 1.25rem; +} + +.detail-section h3 { + margin: 0 0 1rem 0; + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; +} + +.detail-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.detail-item label { + font-size: 0.75rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.detail-item span { + font-size: 0.875rem; + color: hsl(var(--foreground)); + word-break: break-all; +} + +.detail-item span.mono { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.8125rem; + background: hsl(var(--muted) / 0.3); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; +} + +.detail-item-full { + grid-column: 1 / -1; +} + +/* Code Block */ +.code-block { + background: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + padding: 0.75rem 1rem; + overflow-x: auto; +} + +.code-block code { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.8125rem; + color: hsl(var(--foreground)); + white-space: nowrap; +} + /* Field Groups */ .field-group { display: flex; @@ -1927,6 +2087,26 @@ select.cli-input { margin-bottom: 0.75rem; } +/* =========================== + CLI Settings List in Sidebar + =========================== */ + +.cli-settings-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +/* =========================== + Model Pools List in Sidebar + =========================== */ + +.model-pools-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + /* =========================== Main Panel Sections =========================== */ @@ -2763,6 +2943,57 @@ select.cli-input { color: hsl(var(--muted-foreground)); } +/* Parse JSON link in footer */ +.json-parse-link { + font-size: 0.75rem; + color: hsl(var(--primary)); + text-decoration: none; + cursor: pointer; + transition: color 0.2s ease; +} + +.json-parse-link:hover { + color: hsl(var(--primary) / 0.8); + text-decoration: underline; +} + +/* Input with toggle button (for password visibility) */ +.input-with-toggle { + position: relative; + display: flex; + align-items: center; +} + +.input-with-toggle .form-control { + flex: 1; + padding-right: 2.5rem; +} + +.input-with-toggle .toggle-password { + position: absolute; + right: 0.25rem; + top: 50%; + transform: translateY(-50%); + padding: 0.25rem 0.5rem; + background: transparent; + border: none; + color: hsl(var(--muted-foreground)); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.input-with-toggle .toggle-password:hover { + color: hsl(var(--foreground)); +} + +.input-with-toggle .toggle-password i, +.input-with-toggle .toggle-password svg { + width: 16px; + height: 16px; +} + /* Button styles for JSON editor */ .btn-sm { padding: 0.375rem 0.75rem; diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js index 59f6aa0f..1c7e032f 100644 --- a/ccw/src/templates/dashboard-js/components/cli-status.js +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -71,8 +71,8 @@ async function loadAllStatusesFallback() { console.warn('[CLI Status] Using fallback individual API calls'); await Promise.all([ loadCliToolsConfig(), // Ensure config is loaded (auto-creates if missing) - loadCliToolStatus(), - loadCodexLensStatus() + loadCliToolStatus() + // CodexLens status removed - managed in dedicated CodexLens Manager page ]); } @@ -235,8 +235,8 @@ async function loadCliToolsConfig() { const response = await fetch('/api/cli/tools-config'); if (!response.ok) return null; const data = await response.json(); - // Store full config and extract tools for backward compatibility - cliToolsConfig = data.tools || {}; + // Store full config and extract tools object (data.tools is full config, data.tools.tools is the actual tools) + cliToolsConfig = data.tools?.tools || {}; window.claudeCliToolsConfig = data; // Full config available globally // Load default tool from config @@ -308,15 +308,17 @@ async function loadCliSettingsEndpoints() { function updateCliBadge() { const badge = document.getElementById('badgeCliTools'); if (badge) { - // Merge tools from both status and config to get complete list - const allTools = new Set([ - ...Object.keys(cliToolStatus), - ...Object.keys(cliToolsConfig) - ]); + // Only count builtin and cli-wrapper tools (exclude api-endpoint tools) + const cliTools = Object.keys(cliToolsConfig).filter(t => { + if (!t || t === '_configInfo') return false; + const config = cliToolsConfig[t]; + // Include if: no type (legacy builtin), type is builtin, or type is cli-wrapper + return !config?.type || config.type === 'builtin' || config.type === 'cli-wrapper'; + }); - // Count available and enabled CLI tools + // Count available and enabled CLI tools only let available = 0; - allTools.forEach(tool => { + cliTools.forEach(tool => { const status = cliToolStatus[tool] || {}; const config = cliToolsConfig[tool] || { enabled: true }; if (status.available && config.enabled !== false) { @@ -324,33 +326,12 @@ function updateCliBadge() { } }); - // Also count CodexLens and Semantic Search - let totalExtras = 0; - let availableExtras = 0; - - // CodexLens counts if ready - if (codexLensStatus.ready) { - totalExtras++; - availableExtras++; - } else if (codexLensStatus.ready === false) { - // Only count as total if we have status info (not just initial state) - totalExtras++; - } - - // Semantic Search counts if CodexLens is ready (it's a feature of CodexLens) - if (codexLensStatus.ready) { - totalExtras++; - if (semanticStatus.available) { - availableExtras++; - } - } - - const total = allTools.size + totalExtras; - const totalAvailable = available + availableExtras; - badge.textContent = `${totalAvailable}/${total}`; - badge.classList.toggle('text-success', totalAvailable === total && total > 0); - badge.classList.toggle('text-warning', totalAvailable > 0 && totalAvailable < total); - badge.classList.toggle('text-destructive', totalAvailable === 0); + // CLI tools badge shows only CLI tools count + const total = cliTools.length; + badge.textContent = `${available}/${total}`; + badge.classList.toggle('text-success', available === total && total > 0); + badge.classList.toggle('text-warning', available > 0 && available < total); + badge.classList.toggle('text-destructive', available === 0); } } @@ -414,10 +395,16 @@ function renderCliStatus() { }; // Get tools dynamically from config, merging with status for complete list + // Only show builtin and cli-wrapper tools in the tools grid (api-endpoint tools show in API Endpoints section) const tools = [...new Set([ ...Object.keys(cliToolsConfig), ...Object.keys(cliToolStatus) - ])].filter(t => t && t !== '_configInfo'); // Filter out metadata keys + ])].filter(t => { + if (!t || t === '_configInfo') return false; + const config = cliToolsConfig[t]; + // Include if: no type (legacy builtin), type is builtin, or type is cli-wrapper + return !config?.type || config.type === 'builtin' || config.type === 'cli-wrapper'; + }); const toolsHtml = tools.map(tool => { const status = cliToolStatus[tool] || {}; @@ -516,74 +503,8 @@ function renderCliStatus() { `; }).join(''); - // CodexLens card with semantic search info - const codexLensHtml = ` -
-
- - CodexLens - Index -
-
- ${codexLensStatus.ready ? 'Code indexing & FTS search' : 'Full-text code search engine'} -
-
- ${codexLensStatus.ready - ? ` v${codexLensStatus.version || 'installed'}` - : ` Not Installed` - } -
-
- ${!codexLensStatus.ready - ? `` - : ` - ` - } -
-
- `; - - // Semantic Search card (only show if CodexLens is installed) - const semanticHtml = codexLensStatus.ready ? ` -
-
- - Semantic Search - AI -
-
- ${semanticStatus.available ? 'AI-powered code understanding' : 'Natural language code search'} -
-
- ${semanticStatus.available - ? ` ${semanticStatus.backend || 'Ready'}` - : ` Not Installed` - } -
-
- ${!semanticStatus.available ? ` - -
- - ~130MB -
- ` : ` -
- - bge-small-en-v1.5 -
- `} -
-
- ` : ''; + // CodexLens and Semantic Search removed from CLI status panel + // They are managed in the dedicated CodexLens Manager page // CCW Installation Status card (show warning if not fully installed) const ccwInstallHtml = !ccwInstallStatus.installed ? ` @@ -637,6 +558,9 @@ function renderCliStatus() {
${ep.model}
+
+ --tool custom --model ${ep.id} +
`).join('')} @@ -748,8 +672,6 @@ function renderCliStatus() { ${ccwInstallHtml}
${toolsHtml} - ${codexLensHtml} - ${semanticHtml}
${apiEndpointsHtml} ${settingsHtml} diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index d7154702..e2042a18 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -257,6 +257,10 @@ const i18n = { 'cli.addToCli': 'Add to CLI', 'cli.enabled': 'Enabled', 'cli.disabled': 'Disabled', + 'cli.cliWrapper': 'CLI Wrapper', + 'cli.wrapper': 'Wrapper', + 'cli.customClaudeSettings': 'Custom Claude CLI settings', + 'cli.updateFailed': 'Failed to update', // CodexLens Configuration 'codexlens.config': 'CodexLens Configuration', @@ -1618,7 +1622,7 @@ const i18n = { 'apiSettings.total': 'total', 'apiSettings.testConnection': 'Test Connection', 'apiSettings.endpointId': 'Endpoint ID', - 'apiSettings.endpointIdHint': 'Usage: ccw cli -p "..." --model ', + 'apiSettings.endpointIdHint': 'Usage: ccw cli -p "..." --tool custom --model --mode analysis', 'apiSettings.endpoints': 'Endpoints', 'apiSettings.addEndpointHint': 'Create custom endpoint aliases for CLI usage', 'apiSettings.endpointModel': 'Model', @@ -1752,12 +1756,15 @@ const i18n = { 'apiSettings.useModelTreeToManage': 'Use the model tree to manage individual models', // CLI Settings - 'apiSettings.cliSettings': 'CLI Settings', - 'apiSettings.addCliSettings': 'Add CLI Settings', - 'apiSettings.editCliSettings': 'Edit CLI Settings', - 'apiSettings.noCliSettings': 'No CLI settings configured', - 'apiSettings.noCliSettingsSelected': 'No CLI Settings Selected', - 'apiSettings.cliSettingsHint': 'Select a CLI settings endpoint or create a new one', + 'apiSettings.cliSettings': 'CLI Wrapper', + 'apiSettings.addCliSettings': 'Add CLI Wrapper', + 'apiSettings.editCliSettings': 'Edit CLI Wrapper', + 'apiSettings.noCliSettings': 'No CLI wrapper configured', + 'apiSettings.noCliSettingsSelected': 'No CLI Wrapper Selected', + 'apiSettings.cliSettingsHint': 'Select a CLI wrapper endpoint or create a new one', + 'apiSettings.showToken': 'Show', + 'apiSettings.hideToken': 'Hide', + 'apiSettings.syncFromJson': 'Parse JSON', 'apiSettings.cliProviderHint': 'Select an Anthropic provider to use its API key and base URL', 'apiSettings.noAnthropicProviders': 'No Anthropic providers configured. Please add one in the Providers tab first.', 'apiSettings.selectProviderFirst': 'Select a provider first', @@ -1771,6 +1778,10 @@ const i18n = { 'apiSettings.envSettings': 'Environment Settings', 'apiSettings.settingsFilePath': 'Settings File Path', 'apiSettings.nameRequired': 'Name is required', + 'apiSettings.nameInvalidFormat': 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores', + 'apiSettings.nameTooLong': 'Name must be 32 characters or less', + 'apiSettings.nameConflict': 'Name conflicts with built-in tool', + 'apiSettings.nameFormatHint': 'Letters, numbers, hyphens, underscores only. Used as: ccw cli --tool [name]', 'apiSettings.status': 'Status', 'apiSettings.providerBinding': 'Provider Binding', 'apiSettings.directConfig': 'Direct Configuration', @@ -2391,6 +2402,10 @@ const i18n = { 'cli.addToCli': '添加到 CLI', 'cli.enabled': '已启用', 'cli.disabled': '已禁用', + 'cli.cliWrapper': 'CLI 封装', + 'cli.wrapper': '封装', + 'cli.customClaudeSettings': '自定义 Claude CLI 配置', + 'cli.updateFailed': '更新失败', // CodexLens 配置 'codexlens.config': 'CodexLens 配置', @@ -3761,7 +3776,7 @@ const i18n = { 'apiSettings.total': '总计', 'apiSettings.testConnection': '测试连接', 'apiSettings.endpointId': '端点 ID', - 'apiSettings.endpointIdHint': '用法: ccw cli -p "..." --model <端点ID>', + 'apiSettings.endpointIdHint': '用法: ccw cli -p "..." --tool custom --model <端点ID> --mode analysis', 'apiSettings.endpoints': '端点', 'apiSettings.addEndpointHint': '创建用于 CLI 的自定义端点别名', 'apiSettings.endpointModel': '模型', @@ -3895,12 +3910,15 @@ const i18n = { 'apiSettings.useModelTreeToManage': '使用模型树管理各个模型', // CLI Settings - 'apiSettings.cliSettings': 'CLI 配置', - 'apiSettings.addCliSettings': '添加 CLI 配置', - 'apiSettings.editCliSettings': '编辑 CLI 配置', - 'apiSettings.noCliSettings': '未配置 CLI 设置', - 'apiSettings.noCliSettingsSelected': '未选择 CLI 配置', - 'apiSettings.cliSettingsHint': '选择一个 CLI 配置端点或创建新的', + 'apiSettings.cliSettings': 'CLI 封装', + 'apiSettings.addCliSettings': '添加 CLI 封装', + 'apiSettings.editCliSettings': '编辑 CLI 封装', + 'apiSettings.noCliSettings': '未配置 CLI 封装', + 'apiSettings.noCliSettingsSelected': '未选择 CLI 封装', + 'apiSettings.cliSettingsHint': '选择一个 CLI 封装端点或创建新的', + 'apiSettings.showToken': '显示', + 'apiSettings.hideToken': '隐藏', + 'apiSettings.syncFromJson': '解析 JSON', 'apiSettings.cliProviderHint': '选择一个 Anthropic 供应商以使用其 API 密钥和基础 URL', 'apiSettings.noAnthropicProviders': '未配置 Anthropic 供应商。请先在供应商标签页中添加。', 'apiSettings.selectProviderFirst': '请先选择供应商', @@ -3914,6 +3932,10 @@ const i18n = { 'apiSettings.envSettings': '环境变量设置', 'apiSettings.settingsFilePath': '配置文件路径', 'apiSettings.nameRequired': '名称为必填项', + 'apiSettings.nameInvalidFormat': '名称必须以字母开头,只能包含字母、数字、连字符和下划线', + 'apiSettings.nameTooLong': '名称长度不能超过32个字符', + 'apiSettings.nameConflict': '名称与内置工具冲突', + 'apiSettings.nameFormatHint': '仅限字母、数字、连字符、下划线。用于命令: ccw cli --tool [名称]', 'apiSettings.tokenRequired': 'API 令牌为必填项', 'apiSettings.status': '状态', 'apiSettings.providerBinding': '供应商绑定', diff --git a/ccw/src/templates/dashboard-js/views/api-settings.js b/ccw/src/templates/dashboard-js/views/api-settings.js index 688ca503..fb4fd6ed 100644 --- a/ccw/src/templates/dashboard-js/views/api-settings.js +++ b/ccw/src/templates/dashboard-js/views/api-settings.js @@ -1150,6 +1150,13 @@ async function renderApiSettings() { // Load data (use cache by default, forceRefresh=false) await loadApiSettings(false); + // Handle pending CLI wrapper edit from status page navigation + if (window.pendingCliWrapperEdit) { + activeSidebarTab = 'cli-settings'; + selectedCliSettingsId = window.pendingCliWrapperEdit; + window.pendingCliWrapperEdit = null; // Clear the pending edit flag + } + if (!apiSettingsData) { container.innerHTML = '
' + '
' + t('apiSettings.failedToLoad') + '
' + @@ -2707,7 +2714,7 @@ function renderEndpointsList() { '
' + '
' + '' + - 'ccw cli -p "..." --model ' + endpoint.id + '' + + 'ccw cli -p "..." --tool custom --model ' + endpoint.id + ' --mode analysis' + '
' + '' + ''; @@ -3945,7 +3952,8 @@ function renderCliSettingsForm(existingEndpoint) { var commonFieldsHtml = '
' + '' + - '' + + '' + + '' + (t('apiSettings.nameFormatHint') || 'Letters, numbers, hyphens, underscores only. Used as: ccw cli --tool [name]') + '' + '
' + '
' + '' + @@ -4097,7 +4105,12 @@ function renderDirectModeContent(container, env, settings) { container.innerHTML = '
' + '' + + '
' + '' + + '' + + '
' + '
' + '
' + '' + @@ -4151,7 +4164,7 @@ function buildJsonEditorSection(settings) { '
' + '' + '
'; } @@ -4267,6 +4280,39 @@ function validateCliJson() { } } +/** + * Validate CLI endpoint name for CLI compatibility + * Name must be: start with letter, alphanumeric with hyphens/underscores, no spaces + */ +function validateCliEndpointName(name) { + if (!name || name.trim().length === 0) { + return { valid: false, error: t('apiSettings.nameRequired') || 'Name is required' }; + } + + // Check for valid characters: a-z, A-Z, 0-9, hyphen, underscore + var validPattern = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + if (!validPattern.test(name)) { + return { + valid: false, + error: t('apiSettings.nameInvalidFormat') || 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores' + }; + } + + // Check length + if (name.length > 32) { + return { valid: false, error: t('apiSettings.nameTooLong') || 'Name must be 32 characters or less' }; + } + + // Check if name conflicts with built-in tools + var builtinTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode', 'litellm']; + if (builtinTools.indexOf(name.toLowerCase()) !== -1) { + return { valid: false, error: (t('apiSettings.nameConflict') || 'Name conflicts with built-in tool') + ': ' + name }; + } + + return { valid: true }; +} +window.validateCliEndpointName = validateCliEndpointName; + /** * Format JSON in editor */ @@ -4331,6 +4377,87 @@ function syncFormToJson() { } window.syncFormToJson = syncFormToJson; +/** + * Toggle ANTHROPIC_AUTH_TOKEN visibility + */ +function toggleAuthTokenVisibility() { + var input = document.getElementById('cli-auth-token'); + var icon = document.getElementById('cli-auth-token-icon'); + var btn = input ? input.parentElement.querySelector('.toggle-password') : null; + + if (!input || !icon) return; + + if (input.type === 'password') { + input.type = 'text'; + icon.setAttribute('data-lucide', 'eye-off'); + if (btn) btn.title = t('apiSettings.hideToken') || 'Hide'; + } else { + input.type = 'password'; + icon.setAttribute('data-lucide', 'eye'); + if (btn) btn.title = t('apiSettings.showToken') || 'Show'; + } + + if (window.lucide) lucide.createIcons(); +} +window.toggleAuthTokenVisibility = toggleAuthTokenVisibility; + +/** + * Sync JSON editor content to form fields + * Parses JSON and fills ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL and model fields + */ +function syncJsonToForm() { + var editor = document.getElementById('cli-json-editor'); + if (!editor) return; + + var jsonObj; + try { + jsonObj = JSON.parse(editor.value); + } catch (e) { + showRefreshToast(t('apiSettings.jsonInvalid') || 'Invalid JSON', 'error'); + return; + } + + var env = jsonObj.env || {}; + + // Fill ANTHROPIC_AUTH_TOKEN (only in direct mode and only if not masked) + if (cliConfigMode === 'direct') { + var authTokenInput = document.getElementById('cli-auth-token'); + if (authTokenInput && env.ANTHROPIC_AUTH_TOKEN) { + // Only fill if the value is not masked (doesn't end with '...') + if (!env.ANTHROPIC_AUTH_TOKEN.endsWith('...')) { + authTokenInput.value = env.ANTHROPIC_AUTH_TOKEN; + } + } + + var baseUrlInput = document.getElementById('cli-base-url'); + if (baseUrlInput && env.ANTHROPIC_BASE_URL !== undefined) { + baseUrlInput.value = env.ANTHROPIC_BASE_URL || ''; + } + } + + // Fill model configuration fields + var modelDefault = document.getElementById('cli-model-default'); + var modelHaiku = document.getElementById('cli-model-haiku'); + var modelSonnet = document.getElementById('cli-model-sonnet'); + var modelOpus = document.getElementById('cli-model-opus'); + + if (modelDefault && env.ANTHROPIC_MODEL !== undefined) { + modelDefault.value = env.ANTHROPIC_MODEL || ''; + } + if (modelHaiku && env.ANTHROPIC_DEFAULT_HAIKU_MODEL !== undefined) { + modelHaiku.value = env.ANTHROPIC_DEFAULT_HAIKU_MODEL || ''; + } + if (modelSonnet && env.ANTHROPIC_DEFAULT_SONNET_MODEL !== undefined) { + modelSonnet.value = env.ANTHROPIC_DEFAULT_SONNET_MODEL || ''; + } + if (modelOpus && env.ANTHROPIC_DEFAULT_OPUS_MODEL !== undefined) { + modelOpus.value = env.ANTHROPIC_DEFAULT_OPUS_MODEL || ''; + } + + showRefreshToast(t('common.success') || 'Success', 'success'); +} +window.syncJsonToForm = syncJsonToForm; + /** * Get settings from JSON editor (merges with form data) */ @@ -4369,6 +4496,13 @@ async function submitCliSettingsForm() { return; } + // Validate name format for CLI compatibility + var nameValidation = validateCliEndpointName(name); + if (!nameValidation.valid) { + showRefreshToast(nameValidation.error, 'error'); + return; + } + var data = { name: name, description: description, @@ -4603,7 +4737,8 @@ function showAddCliSettingsModal(existingEndpoint) { (isEdit ? '' : '') + '
' + '' + - '' + + '' + + '' + (t('apiSettings.nameFormatHint') || 'Letters, numbers, hyphens, underscores only. Used as: ccw cli --tool [name]') + '' + '
' + '
' + '' + @@ -4674,6 +4809,13 @@ async function submitCliSettings() { return; } + // Validate name format for CLI compatibility + var nameValidation = validateCliEndpointName(name); + if (!nameValidation.valid) { + showRefreshToast(nameValidation.error, 'error'); + return; + } + if (!providerId) { showRefreshToast(t('apiSettings.providerRequired'), 'error'); return; diff --git a/ccw/src/templates/dashboard-js/views/cli-manager.js b/ccw/src/templates/dashboard-js/views/cli-manager.js index a94eb112..31d0c0ac 100644 --- a/ccw/src/templates/dashboard-js/views/cli-manager.js +++ b/ccw/src/templates/dashboard-js/views/cli-manager.js @@ -6,6 +6,7 @@ var currentCliExecution = null; var cliExecutionOutput = ''; var ccwInstallations = []; var ccwEndpointTools = []; +var cliWrapperEndpoints = []; // CLI封装 endpoints from /api/cli/settings var cliToolConfig = null; // Store loaded CLI config var predefinedModels = {}; // Store predefined models per tool @@ -193,6 +194,46 @@ async function loadCliCustomEndpoints() { } } +// ========== CLI Wrapper Endpoints (CLI封装) ========== +async function loadCliWrapperEndpoints() { + try { + var response = await fetch('/api/cli/settings'); + if (!response.ok) throw new Error('Failed to load CLI wrapper endpoints'); + var data = await response.json(); + cliWrapperEndpoints = data.endpoints || []; + return cliWrapperEndpoints; + } catch (err) { + console.error('Failed to load CLI wrapper endpoints:', err); + cliWrapperEndpoints = []; + return []; + } +} + +async function toggleCliWrapperEnabled(endpointId, enabled) { + try { + await initCsrfToken(); + var response = await csrfFetch('/api/cli/settings/' + endpointId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: enabled }) + }); + if (!response.ok) throw new Error('Failed to update CLI wrapper endpoint'); + var data = await response.json(); + if (data.success) { + // Update local state + var idx = cliWrapperEndpoints.findIndex(function(e) { return e.id === endpointId; }); + if (idx >= 0) { + cliWrapperEndpoints[idx].enabled = enabled; + } + showRefreshToast((enabled ? t('cli.enabled') || 'Enabled' : t('cli.disabled') || 'Disabled') + ': ' + endpointId, 'success'); + } + return data; + } catch (err) { + showRefreshToast((t('cli.updateFailed') || 'Failed to update') + ': ' + err.message, 'error'); + throw err; + } +} + async function toggleEndpointEnabled(endpointId, enabled) { try { await initCsrfToken(); @@ -628,7 +669,8 @@ async function renderCliManager() { loadCcwInstallations(), loadCcwEndpointTools(), loadLitellmApiEndpoints(), - loadCliCustomEndpoints() + loadCliCustomEndpoints(), + loadCliWrapperEndpoints() ]); container.innerHTML = '
' + @@ -764,44 +806,8 @@ function renderToolsSection() { '
'; }).join(''); - // CodexLens item - simplified view with link to manager page - var codexLensHtml = '
' + - '
' + - '' + - '
' + - '
CodexLens Index' + - '
' + - '
' + (codexLensStatus.ready ? t('cli.codexLensDesc') : t('cli.codexLensDescFull')) + '
' + - '
' + - '
' + - '
' + - (codexLensStatus.ready - ? ' v' + (codexLensStatus.version || 'installed') + '' + - '' - : ' ' + t('cli.notInstalled') + '' + - '') + - '
' + - '
'; - - // Semantic Search item (only show if CodexLens is installed) - var semanticHtml = ''; - if (codexLensStatus.ready) { - semanticHtml = '
' + - '
' + - '' + - '
' + - '
Semantic Search AI
' + - '
' + (semanticStatus.available ? 'AI-powered code understanding' : 'Natural language code search') + '
' + - '
' + - '
' + - '
' + - (semanticStatus.available - ? ' ' + (semanticStatus.backend || 'Ready') + '' - : ' Not Installed' + - '') + - '
' + - '
'; - } + // CodexLens and Semantic Search removed from this list + // They are managed in the dedicated CodexLens Manager page (left menu) // API Endpoints section var apiEndpointsHtml = ''; @@ -848,6 +854,45 @@ function renderToolsSection() { '
'; } + // CLI Wrapper (CLI封装) section + var cliWrapperHtml = ''; + if (cliWrapperEndpoints.length > 0) { + var wrapperItems = cliWrapperEndpoints.map(function(endpoint) { + var isEnabled = endpoint.enabled !== false; + var desc = endpoint.description || (t('cli.customClaudeSettings') || 'Custom Claude CLI settings'); + // Show command hint with name for easy copying + var commandHint = 'ccw cli --tool ' + endpoint.name; + + return '
' + + '
' + + '' + + '
' + + '
' + escapeHtml(endpoint.name) + ' ' + (t('cli.wrapper') || 'Wrapper') + '
' + + '
' + escapeHtml(desc) + '
' + + '
' + escapeHtml(commandHint) + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
'; + }).join(''); + + var enabledCount = cliWrapperEndpoints.filter(function(e) { return e.enabled !== false; }).length; + cliWrapperHtml = '
' + + '
' + + '

' + + ' ' + (t('cli.cliWrapper') || 'CLI Wrapper') + + '

' + + '' + enabledCount + '/' + cliWrapperEndpoints.length + ' ' + (t('cli.enabled') || 'enabled') + '' + + '
' + + '
' + wrapperItems + '
' + + '
'; + } + container.innerHTML = '
' + '
' + '

' + t('cli.tools') + '

' + @@ -859,14 +904,34 @@ function renderToolsSection() { '
' + '
' + toolsHtml + - codexLensHtml + - semanticHtml + '
' + - apiEndpointsHtml; + apiEndpointsHtml + + cliWrapperHtml; if (window.lucide) lucide.createIcons(); } +/** + * Navigate to API Settings page and open the CLI wrapper endpoint for editing + */ +function navigateToApiSettings(endpointId) { + // Store the endpoint ID to edit after navigation + window.pendingCliWrapperEdit = endpointId; + + var navItem = document.querySelector('.nav-item[data-view="api-settings"]'); + if (navItem) { + navItem.click(); + } else { + // Fallback: try to render directly + if (typeof renderApiSettings === 'function') { + currentView = 'api-settings'; + renderApiSettings(); + } else { + showRefreshToast(t('common.error') + ': API Settings not available', 'error'); + } + } +} + // ========== CCW Section (Right Column) ========== function renderCcwSection() { var container = document.getElementById('ccw-section'); diff --git a/ccw/src/templates/dashboard-js/views/codexlens-manager.js b/ccw/src/templates/dashboard-js/views/codexlens-manager.js index 7d23918b..6a548e6c 100644 --- a/ccw/src/templates/dashboard-js/views/codexlens-manager.js +++ b/ccw/src/templates/dashboard-js/views/codexlens-manager.js @@ -4497,6 +4497,53 @@ function buildCodexLensManagerPage(config) { '
Click Load to view/edit ~/.codexlens/.env
' + '
' + '' + + // File Watcher Card (moved from right column) + '
' + + '
' + + '
' + + '
' + + '' + + '

File Watcher

' + + '
' + + '
' + + 'Stopped' + + '' + + '
' + + '
' + + '
' + + '
' + + '

Monitor file changes and auto-update index

' + + // Stats row + '
' + + '
' + + '
-
' + + '
Files
' + + '
' + + '
' + + '
0
' + + '
Changes
' + + '
' + + '
' + + '
-
' + + '
Uptime
' + + '
' + + '
' + + // Recent activity log + '
' + + '
' + + 'Recent Activity' + + '' + + '
' + + '
' + + '
No activity yet. Start watcher to monitor files.
' + + '
' + + '
' + + '
' + + '
' + '' + // Right Column '
' + @@ -4544,53 +4591,6 @@ function buildCodexLensManagerPage(config) { '
' + '' + '' + - // File Watcher Card - '
' + - '
' + - '
' + - '
' + - '' + - '

File Watcher

' + - '
' + - '
' + - 'Stopped' + - '' + - '
' + - '
' + - '
' + - '
' + - '

Monitor file changes and auto-update index

' + - // Stats row - '
' + - '
' + - '
-
' + - '
Files
' + - '
' + - '
' + - '
0
' + - '
Changes
' + - '
' + - '
' + - '
-
' + - '
Uptime
' + - '
' + - '
' + - // Recent activity log - '
' + - '
' + - 'Recent Activity' + - '' + - '
' + - '
' + - '
No activity yet. Start watcher to monitor files.
' + - '
' + - '
' + - '
' + - '
' + '' + '' + // Ignore Patterns Section diff --git a/ccw/src/tools/claude-cli-tools.ts b/ccw/src/tools/claude-cli-tools.ts index 533b3ef9..9cc15ca0 100644 --- a/ccw/src/tools/claude-cli-tools.ts +++ b/ccw/src/tools/claude-cli-tools.ts @@ -22,10 +22,20 @@ export interface ClaudeCliTool { primaryModel?: string; secondaryModel?: string; tags: string[]; + type?: 'builtin' | 'cli-wrapper' | 'api-endpoint'; // Tool type: builtin, cli-wrapper, or api-endpoint + id?: string; // Required for api-endpoint type (endpoint ID for settings lookup) } -export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode'; +export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode' | string; +// @deprecated Use tools with type: 'api-endpoint' instead +export interface ClaudeApiEndpoint { + id: string; + name: string; + enabled: boolean; +} + +// @deprecated Use tools with type: 'cli-wrapper' or 'api-endpoint' instead export interface ClaudeCustomEndpoint { id: string; name: string; @@ -44,8 +54,9 @@ export interface ClaudeCliToolsConfig { $schema?: string; version: string; models?: Record; // PREDEFINED_MODELS - tools: Record; - customEndpoints: ClaudeCustomEndpoint[]; + tools: Record; // All tools: builtin, cli-wrapper, api-endpoint + apiEndpoints?: ClaudeApiEndpoint[]; // @deprecated Use tools with type: 'api-endpoint' instead + customEndpoints?: ClaudeCustomEndpoint[]; // @deprecated Use tools with type: 'cli-wrapper' or 'api-endpoint' instead } // New: Settings-only config (cli-settings.json) @@ -103,41 +114,46 @@ const PREDEFINED_MODELS: Record = { }; const DEFAULT_TOOLS_CONFIG: ClaudeCliToolsConfig = { - version: '3.0.0', + version: '3.2.0', models: { ...PREDEFINED_MODELS }, tools: { gemini: { enabled: true, primaryModel: 'gemini-2.5-pro', secondaryModel: 'gemini-2.5-flash', - tags: [] + tags: [], + type: 'builtin' }, qwen: { enabled: true, primaryModel: 'coder-model', secondaryModel: 'coder-model', - tags: [] + tags: [], + type: 'builtin' }, codex: { enabled: true, primaryModel: 'gpt-5.2', secondaryModel: 'gpt-5.2', - tags: [] + tags: [], + type: 'builtin' }, claude: { enabled: true, primaryModel: 'sonnet', secondaryModel: 'haiku', - tags: [] + tags: [], + type: 'builtin' }, opencode: { enabled: true, primaryModel: 'opencode/glm-4.7-free', secondaryModel: 'opencode/glm-4.7-free', - tags: [] + tags: [], + type: 'builtin' } - }, - customEndpoints: [] + } + // Note: api-endpoint type tools are added dynamically via addClaudeApiEndpoint }; const DEFAULT_SETTINGS_CONFIG: ClaudeCliSettingsConfig = { @@ -222,17 +238,18 @@ function ensureToolTags(tool: Partial): ClaudeCliTool { } /** - * Migrate config from older versions to v3.0.0 + * Migrate config from older versions to v3.2.0 + * v3.2.0: All endpoints (cli-wrapper, api-endpoint) are in tools with type field */ function migrateConfig(config: any, projectDir: string): ClaudeCliToolsConfig { const version = parseFloat(config.version || '1.0'); - // Already v3.x, no migration needed - if (version >= 3.0) { + // Already v3.2+, no migration needed + if (version >= 3.2) { return config as ClaudeCliToolsConfig; } - console.log(`[claude-cli-tools] Migrating config from v${config.version || '1.0'} to v3.0.0`); + console.log(`[claude-cli-tools] Migrating config from v${config.version || '1.0'} to v3.2.0`); // Try to load legacy cli-config.json for model data let legacyCliConfig: any = null; @@ -258,7 +275,9 @@ function migrateConfig(config: any, projectDir: string): ClaudeCliToolsConfig { enabled: t.enabled ?? legacyTool?.enabled ?? true, primaryModel: t.primaryModel ?? legacyTool?.primaryModel ?? DEFAULT_TOOLS_CONFIG.tools[key]?.primaryModel, secondaryModel: t.secondaryModel ?? legacyTool?.secondaryModel ?? DEFAULT_TOOLS_CONFIG.tools[key]?.secondaryModel, - tags: t.tags ?? legacyTool?.tags ?? [] + tags: t.tags ?? legacyTool?.tags ?? [], + type: t.type ?? DEFAULT_TOOLS_CONFIG.tools[key]?.type ?? 'builtin', + id: t.id // Preserve id for api-endpoint type }; } @@ -270,16 +289,57 @@ function migrateConfig(config: any, projectDir: string): ClaudeCliToolsConfig { enabled: legacyTool?.enabled ?? defaultTool.enabled, primaryModel: legacyTool?.primaryModel ?? defaultTool.primaryModel, secondaryModel: legacyTool?.secondaryModel ?? defaultTool.secondaryModel, - tags: legacyTool?.tags ?? defaultTool.tags + tags: legacyTool?.tags ?? defaultTool.tags, + type: defaultTool.type ?? 'builtin' }; } } + // Migrate customEndpoints (v3.0 and below): cli-wrapper -> tools, others -> api-endpoint tools + const customEndpoints = config.customEndpoints || []; + for (const ep of customEndpoints) { + if (ep.tags?.includes('cli-wrapper')) { + // CLI wrapper becomes a tool with type: 'cli-wrapper' + if (!migratedTools[ep.name]) { + migratedTools[ep.name] = { + enabled: ep.enabled ?? true, + tags: ep.tags.filter((t: string) => t !== 'cli-wrapper'), + type: 'cli-wrapper' + }; + console.log(`[claude-cli-tools] Migrated cli-wrapper "${ep.name}" to tools`); + } + } else { + // Pure API endpoint becomes a tool with type: 'api-endpoint' + if (!migratedTools[ep.name]) { + migratedTools[ep.name] = { + enabled: ep.enabled ?? true, + tags: [], + type: 'api-endpoint', + id: ep.id // Store endpoint ID for settings lookup + }; + console.log(`[claude-cli-tools] Migrated API endpoint "${ep.name}" to tools`); + } + } + } + + // Migrate apiEndpoints (v3.1): convert to tools with type: 'api-endpoint' + const apiEndpoints = config.apiEndpoints || []; + for (const ep of apiEndpoints) { + if (!migratedTools[ep.name]) { + migratedTools[ep.name] = { + enabled: ep.enabled ?? true, + tags: [], + type: 'api-endpoint', + id: ep.id // Store endpoint ID for settings lookup + }; + console.log(`[claude-cli-tools] Migrated API endpoint "${ep.name}" to tools`); + } + } + return { - version: '3.0.0', + version: '3.2.0', models: { ...PREDEFINED_MODELS }, tools: migratedTools, - customEndpoints: config.customEndpoints || [], $schema: config.$schema }; } @@ -324,7 +384,7 @@ export function ensureClaudeCliTools(projectDir: string, createInProject: boolea * Load CLI tools configuration from global ~/.claude/cli-tools.json * Falls back to default config if not found. * - * Automatically migrates older config versions to v3.0.0 + * Automatically migrates older config versions to v3.2.0 */ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & { _source?: string } { const resolved = resolveConfigPath(projectDir); @@ -337,27 +397,24 @@ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & { const content = fs.readFileSync(resolved.path, 'utf-8'); const parsed = JSON.parse(content) as Partial; - // Migrate older versions to v3.0.0 + // Migrate older versions to v3.2.0 const migrated = migrateConfig(parsed, projectDir); const needsSave = migrated.version !== parsed.version; // Merge tools with defaults and ensure required fields exist const mergedTools: Record = {}; for (const [key, tool] of Object.entries({ ...DEFAULT_TOOLS_CONFIG.tools, ...(migrated.tools || {}) })) { - mergedTools[key] = ensureToolTags(tool); + mergedTools[key] = { + ...ensureToolTags(tool), + type: tool.type ?? 'builtin', + id: tool.id // Preserve id for api-endpoint type + }; } - // Ensure customEndpoints have tags - const mergedEndpoints = (migrated.customEndpoints || []).map(ep => ({ - ...ep, - tags: ep.tags ?? [] - })); - const config: ClaudeCliToolsConfig & { _source?: string } = { version: migrated.version || DEFAULT_TOOLS_CONFIG.version, models: migrated.models || DEFAULT_TOOLS_CONFIG.models, tools: mergedTools, - customEndpoints: mergedEndpoints, $schema: migrated.$schema, _source: resolved.source }; @@ -513,27 +570,43 @@ export function updateClaudeDefaultTool( } /** - * Add custom endpoint + * Add API endpoint as a tool with type: 'api-endpoint' + * Usage: --tool or --tool custom --model */ -export function addClaudeCustomEndpoint( +export function addClaudeApiEndpoint( projectDir: string, - endpoint: { id: string; name: string; enabled: boolean; tags?: string[] } + endpoint: { id: string; name: string; enabled: boolean } ): ClaudeCliToolsConfig { const config = loadClaudeCliTools(projectDir); - const newEndpoint: ClaudeCustomEndpoint = { - id: endpoint.id, - name: endpoint.name, + // Add as a tool with type: 'api-endpoint' + config.tools[endpoint.name] = { enabled: endpoint.enabled, - tags: endpoint.tags || [] + tags: [], + type: 'api-endpoint', + id: endpoint.id // Store endpoint ID for settings lookup }; - // Check if endpoint already exists - const existingIndex = config.customEndpoints.findIndex(e => e.id === endpoint.id); - if (existingIndex >= 0) { - config.customEndpoints[existingIndex] = newEndpoint; - } else { - config.customEndpoints.push(newEndpoint); + saveClaudeCliTools(projectDir, config); + return config; +} + +/** + * Remove API endpoint tool by id or name + */ +export function removeClaudeApiEndpoint( + projectDir: string, + endpointId: string +): ClaudeCliToolsConfig { + const config = loadClaudeCliTools(projectDir); + + // Find the tool by id or name + const toolToRemove = Object.entries(config.tools).find( + ([name, t]) => t.type === 'api-endpoint' && (t.id === endpointId || name === endpointId || name.toLowerCase() === endpointId.toLowerCase()) + ); + + if (toolToRemove) { + delete config.tools[toolToRemove[0]]; } saveClaudeCliTools(projectDir, config); @@ -541,14 +614,57 @@ export function addClaudeCustomEndpoint( } /** - * Remove custom endpoint + * @deprecated Use addClaudeApiEndpoint instead + * Adds tool to config based on tags: + * - cli-wrapper tag -> type: 'cli-wrapper' + * - others -> type: 'api-endpoint' + */ +export function addClaudeCustomEndpoint( + projectDir: string, + endpoint: { id: string; name: string; enabled: boolean; tags?: string[] } +): ClaudeCliToolsConfig { + const config = loadClaudeCliTools(projectDir); + + if (endpoint.tags?.includes('cli-wrapper')) { + // CLI wrapper tool + config.tools[endpoint.name] = { + enabled: endpoint.enabled, + tags: endpoint.tags.filter(t => t !== 'cli-wrapper'), + type: 'cli-wrapper' + }; + } else { + // API endpoint tool + config.tools[endpoint.name] = { + enabled: endpoint.enabled, + tags: [], + type: 'api-endpoint', + id: endpoint.id + }; + } + + saveClaudeCliTools(projectDir, config); + return config; +} + +/** + * Remove endpoint tool (cli-wrapper or api-endpoint) */ export function removeClaudeCustomEndpoint( projectDir: string, endpointId: string ): ClaudeCliToolsConfig { const config = loadClaudeCliTools(projectDir); - config.customEndpoints = config.customEndpoints.filter(e => e.id !== endpointId); + + // Find the tool by id or name (cli-wrapper or api-endpoint type) + const toolToRemove = Object.entries(config.tools).find( + ([name, t]) => (t.type === 'cli-wrapper' || t.type === 'api-endpoint') && + (name === endpointId || name.toLowerCase() === endpointId.toLowerCase() || t.id === endpointId) + ); + + if (toolToRemove) { + delete config.tools[toolToRemove[0]]; + } + saveClaudeCliTools(projectDir, config); return config; } diff --git a/ccw/src/tools/cli-executor-core.ts b/ccw/src/tools/cli-executor-core.ts index 03a6a184..ed5e7ef9 100644 --- a/ccw/src/tools/cli-executor-core.ts +++ b/ccw/src/tools/cli-executor-core.ts @@ -80,6 +80,179 @@ export function killCurrentCliProcess(): boolean { import { executeLiteLLMEndpoint } from './litellm-executor.js'; import { findEndpointById } from '../config/litellm-api-config-manager.js'; +// CLI Settings (CLI封装) integration +import { loadEndpointSettings, getSettingsFilePath, findEndpoint } from '../config/cli-settings-manager.js'; +import { loadClaudeCliTools } from './claude-cli-tools.js'; + +/** + * Execute Claude CLI with custom settings file (CLI封装) + */ +interface ClaudeWithSettingsParams { + prompt: string; + settingsPath: string; + endpointId: string; + mode: 'analysis' | 'write' | 'auto'; + workingDir: string; + cd?: string; + includeDirs?: string[]; + customId?: string; + onOutput?: (unit: CliOutputUnit) => void; +} + +async function executeClaudeWithSettings(params: ClaudeWithSettingsParams): Promise { + const { prompt, settingsPath, endpointId, mode, workingDir, cd, includeDirs, customId, onOutput } = params; + + const startTime = Date.now(); + const conversationId = customId || `${Date.now()}-${endpointId}`; + + // Build claude command with --settings flag + const args: string[] = [ + '--settings', settingsPath, + '--print' // Non-interactive mode + ]; + + // Add mode-specific flags + if (mode === 'write') { + args.push('--dangerously-skip-permissions'); + } + + // Add working directory if specified + if (cd) { + args.push('--cd', cd); + } + + // Add include directories + if (includeDirs && includeDirs.length > 0) { + for (const dir of includeDirs) { + args.push('--add-dir', dir); + } + } + + // Add prompt as argument + args.push('-p', prompt); + + debugLog('CLAUDE_SETTINGS', `Executing claude with settings`, { + settingsPath, + endpointId, + mode, + workingDir, + args + }); + + return new Promise((resolve, reject) => { + const isWindows = process.platform === 'win32'; + const command = 'claude'; + const commandToSpawn = isWindows ? escapeWindowsArg(command) : command; + const argsToSpawn = isWindows ? args.map(escapeWindowsArg) : args; + + const child = spawn(commandToSpawn, argsToSpawn, { + cwd: workingDir, + shell: isWindows, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + // Track current child process for cleanup + currentChildProcess = child; + + let stdout = ''; + let stderr = ''; + const outputUnits: CliOutputUnit[] = []; + + child.stdout!.on('data', (data: Buffer) => { + const text = data.toString(); + stdout += text; + + const unit: CliOutputUnit = { + type: 'stdout', + content: text, + timestamp: new Date().toISOString() + }; + outputUnits.push(unit); + + if (onOutput) { + onOutput(unit); + } + }); + + child.stderr!.on('data', (data: Buffer) => { + const text = data.toString(); + stderr += text; + + const unit: CliOutputUnit = { + type: 'stderr', + content: text, + timestamp: new Date().toISOString() + }; + outputUnits.push(unit); + + if (onOutput) { + onOutput(unit); + } + }); + + child.on('close', (code) => { + currentChildProcess = null; + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Determine status + let status: 'success' | 'error' = 'success'; + if (code !== 0) { + const hasValidOutput = stdout.trim().length > 0; + const hasFatalError = stderr.includes('FATAL') || + stderr.includes('Authentication failed') || + stderr.includes('API key'); + + if (hasValidOutput && !hasFatalError) { + status = 'success'; + } else { + status = 'error'; + } + } + + const execution: ExecutionRecord = { + id: conversationId, + timestamp: new Date(startTime).toISOString(), + tool: 'claude', + model: endpointId, // Use endpoint ID as model identifier + mode, + prompt, + status, + exit_code: code, + duration_ms: duration, + output: { + stdout: stdout.substring(0, 10240), + stderr: stderr.substring(0, 2048), + truncated: stdout.length > 10240 || stderr.length > 2048 + } + }; + + const conversation = convertToConversation(execution); + + // Save to history + try { + saveConversation(workingDir, conversation); + } catch (err) { + console.error('[CLI Executor] Failed to save CLI封装 history:', (err as Error).message); + } + + resolve({ + success: status === 'success', + execution, + conversation, + stdout, + stderr + }); + }); + + child.on('error', (error) => { + currentChildProcess = null; + reject(new Error(`Failed to spawn claude: ${error.message}`)); + }); + }); +} + // Native resume support import { trackNewSession, @@ -100,9 +273,14 @@ import { getPrimaryModel } from './cli-config-manager.js'; +// Built-in CLI tools +const BUILTIN_CLI_TOOLS = ['gemini', 'qwen', 'codex', 'opencode', 'claude'] as const; +type BuiltinCliTool = typeof BUILTIN_CLI_TOOLS[number]; + // Define Zod schema for validation +// tool accepts built-in tools or custom endpoint IDs (CLI封装) const ParamsSchema = z.object({ - tool: z.enum(['gemini', 'qwen', 'codex', 'opencode']), + tool: z.string().min(1, 'Tool is required'), // Accept any tool ID (built-in or custom endpoint) prompt: z.string().min(1, 'Prompt is required'), mode: z.enum(['analysis', 'write', 'auto']).default('analysis'), format: z.enum(['plain', 'yaml', 'json']).default('plain'), // Multi-turn prompt concatenation format @@ -220,6 +398,116 @@ async function executeCliTool( } } + // Check if tool is a custom CLI封装 endpoint (not a built-in tool) + const isBuiltinTool = BUILTIN_CLI_TOOLS.includes(tool as BuiltinCliTool); + if (!isBuiltinTool) { + // Check if it's a CLI封装 endpoint (by ID or name) + const cliSettings = findEndpoint(tool); + if (cliSettings && cliSettings.enabled) { + // Route to Claude CLI with --settings flag + const settingsPath = getSettingsFilePath(cliSettings.id); + const displayName = cliSettings.name !== cliSettings.id ? `${cliSettings.name} (${cliSettings.id})` : cliSettings.id; + if (onOutput) { + onOutput({ + type: 'stderr', + content: `[Routing to CLI封装 endpoint: ${displayName} via claude --settings]\n`, + timestamp: new Date().toISOString() + }); + } + + // Execute claude CLI with settings file + const result = await executeClaudeWithSettings({ + prompt, + settingsPath, + endpointId: cliSettings.id, + mode, + workingDir, + cd, + includeDirs: includeDirs ? includeDirs.split(',').map(d => d.trim()) : undefined, + customId, + onOutput: onOutput || undefined + }); + + return result; + } + + // Check cli-tools.json for CLI wrapper tools or API endpoints + const cliToolsConfig = loadClaudeCliTools(workingDir); + + // First check if tool is a cli-wrapper in tools section + const cliWrapperTool = Object.entries(cliToolsConfig.tools).find( + ([name, t]) => name.toLowerCase() === tool.toLowerCase() && t.type === 'cli-wrapper' && t.enabled + ); + if (cliWrapperTool) { + const [toolName] = cliWrapperTool; + // Check if there's a corresponding CLI封装 settings file + const cliSettingsForTool = findEndpoint(toolName); + if (cliSettingsForTool) { + const settingsPath = getSettingsFilePath(cliSettingsForTool.id); + if (onOutput) { + onOutput({ + type: 'stderr', + content: `[Routing to CLI wrapper tool: ${toolName} via claude --settings]\n`, + timestamp: new Date().toISOString() + }); + } + + const result = await executeClaudeWithSettings({ + prompt, + settingsPath, + endpointId: cliSettingsForTool.id, + mode, + workingDir, + cd, + includeDirs: includeDirs ? includeDirs.split(',').map(d => d.trim()) : undefined, + customId, + onOutput: onOutput || undefined + }); + + return result; + } + } + + // Check tools with type: 'api-endpoint' (for --tool custom --model ) + const apiEndpointTool = Object.entries(cliToolsConfig.tools).find( + ([name, t]) => t.type === 'api-endpoint' && t.enabled && + (t.id === tool || name === tool || name.toLowerCase() === tool.toLowerCase()) + ); + if (apiEndpointTool) { + const [toolName, toolConfig] = apiEndpointTool; + const endpointId = toolConfig.id || toolName; + // Check if there's a corresponding CLI封装 settings file + const cliSettingsForEndpoint = findEndpoint(endpointId); + if (cliSettingsForEndpoint) { + const settingsPath = getSettingsFilePath(cliSettingsForEndpoint.id); + if (onOutput) { + onOutput({ + type: 'stderr', + content: `[Routing to API endpoint: ${toolName} via claude --settings]\n`, + timestamp: new Date().toISOString() + }); + } + + const result = await executeClaudeWithSettings({ + prompt, + settingsPath, + endpointId: cliSettingsForEndpoint.id, + mode, + workingDir, + cd, + includeDirs: includeDirs ? includeDirs.split(',').map(d => d.trim()) : undefined, + customId, + onOutput: onOutput || undefined + }); + + return result; + } + } + + // Tool not found + throw new Error(`Unknown tool: ${tool}. Use one of: ${BUILTIN_CLI_TOOLS.join(', ')} or a registered CLI封装 endpoint name.`); + } + // Get SQLite store for native session lookup const store = await getSqliteStore(workingDir);