From e1eafede6564195db360616f474f946401f129d6 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 11 Jan 2026 20:40:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20CLI=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=EF=BC=8C=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E5=8A=A0=E8=BD=BD=E5=B7=A5=E5=85=B7=E5=B9=B6=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E9=85=8D=E7=BD=AE=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 1 - .../dashboard-js/components/cli-status.js | 83 +++++++++++++--- .../dashboard-js/views/cli-manager.js | 2 + ccw/src/tools/claude-cli-tools.ts | 99 +++++++------------ ccw/src/tools/cli-executor-core.ts | 21 +++- ccw/src/utils/shell-escape.ts | 6 +- 6 files changed, 134 insertions(+), 78 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 689d86d1..8d259441 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -15,7 +15,6 @@ Available CLI endpoints are dynamically defined by the config file: - Managed through the CCW Dashboard Status page - ## Tool Execution - **Context Requirements**: @~/.claude/workflows/context-tools.md diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js index 793306e9..59f6aa0f 100644 --- a/ccw/src/templates/dashboard-js/components/cli-status.js +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -2,7 +2,7 @@ // Displays CLI tool availability status and allows setting default tool // ========== CLI State ========== -let cliToolStatus = { gemini: {}, qwen: {}, codex: {}, claude: {} }; +let cliToolStatus = {}; // Dynamically populated from config let codexLensStatus = { ready: false }; let semanticStatus = { available: false }; let ccwInstallStatus = { installed: true, workflowsInstalled: true, missingFiles: [], installPath: '' }; @@ -38,8 +38,8 @@ async function loadAllStatuses() { if (!response.ok) throw new Error('Failed to load status'); const data = await response.json(); - // Update all status data - cliToolStatus = data.cli || { gemini: {}, qwen: {}, codex: {}, claude: {} }; + // Update all status data - merge with config tools to ensure all tools are tracked + cliToolStatus = data.cli || {}; codexLensStatus = data.codexLens || { ready: false }; semanticStatus = data.semantic || { available: false }; ccwInstallStatus = data.ccwInstall || { installed: true, workflowsInstalled: true, missingFiles: [], installPath: '' }; @@ -70,6 +70,7 @@ async function loadAllStatuses() { 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() ]); @@ -307,12 +308,49 @@ async function loadCliSettingsEndpoints() { function updateCliBadge() { const badge = document.getElementById('badgeCliTools'); if (badge) { - const available = Object.values(cliToolStatus).filter(t => t.available).length; - const total = Object.keys(cliToolStatus).length; - badge.textContent = `${available}/${total}`; - badge.classList.toggle('text-success', available === total); - badge.classList.toggle('text-warning', available > 0 && available < total); - badge.classList.toggle('text-destructive', available === 0); + // Merge tools from both status and config to get complete list + const allTools = new Set([ + ...Object.keys(cliToolStatus), + ...Object.keys(cliToolsConfig) + ]); + + // Count available and enabled CLI tools + let available = 0; + allTools.forEach(tool => { + const status = cliToolStatus[tool] || {}; + const config = cliToolsConfig[tool] || { enabled: true }; + if (status.available && config.enabled !== false) { + available++; + } + }); + + // 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); } } @@ -353,17 +391,33 @@ function renderCliStatus() { gemini: 'Google AI for code analysis', qwen: 'Alibaba AI assistant', codex: 'OpenAI code generation', - claude: 'Anthropic AI assistant' + claude: 'Anthropic AI assistant', + opencode: 'OpenCode multi-model API' }; const toolIcons = { gemini: 'sparkle', qwen: 'bot', codex: 'code-2', - claude: 'brain' + claude: 'brain', + opencode: 'globe' // Default icon for new tools }; - const tools = ['gemini', 'qwen', 'codex', 'claude']; + // Helper to get description for any tool (with fallback) + const getToolDescription = (tool) => { + return toolDescriptions[tool] || `${tool.charAt(0).toUpperCase() + tool.slice(1)} CLI tool`; + }; + + // Helper to get icon for any tool (with fallback) + const getToolIcon = (tool) => { + return toolIcons[tool] || 'terminal'; + }; + + // Get tools dynamically from config, merging with status for complete list + const tools = [...new Set([ + ...Object.keys(cliToolsConfig), + ...Object.keys(cliToolStatus) + ])].filter(t => t && t !== '_configInfo'); // Filter out metadata keys const toolsHtml = tools.map(tool => { const status = cliToolStatus[tool] || {}; @@ -429,7 +483,7 @@ function renderCliStatus() { ${cliSettingsBadge}
- ${toolDescriptions[tool]} + ${getToolDescription(tool)}
@@ -810,7 +864,8 @@ async function refreshAllCliStatus() { async function toggleCliTool(tool, enabled) { // If disabling the current default tool, switch to another available+enabled tool if (!enabled && defaultCliTool === tool) { - const tools = ['gemini', 'qwen', 'codex', 'claude']; + // Get tools dynamically from config + const tools = Object.keys(cliToolsConfig).filter(t => t && t !== '_configInfo'); const newDefault = tools.find(t => { if (t === tool) return false; const status = cliToolStatus[t] || {}; diff --git a/ccw/src/templates/dashboard-js/views/cli-manager.js b/ccw/src/templates/dashboard-js/views/cli-manager.js index 9dea6a18..a94eb112 100644 --- a/ccw/src/templates/dashboard-js/views/cli-manager.js +++ b/ccw/src/templates/dashboard-js/views/cli-manager.js @@ -620,7 +620,9 @@ async function renderCliManager() { if (searchInput) searchInput.parentElement.style.display = 'none'; // Load data (including CodexLens status for tools section) + // loadCliToolsConfig() ensures cli-tools.json is auto-created if missing await Promise.all([ + loadCliToolsConfig(), loadCliToolStatus(), loadCodexLensStatus(), loadCcwInstallations(), diff --git a/ccw/src/tools/claude-cli-tools.ts b/ccw/src/tools/claude-cli-tools.ts index a8ee5db7..533b3ef9 100644 --- a/ccw/src/tools/claude-cli-tools.ts +++ b/ccw/src/tools/claude-cli-tools.ts @@ -1,15 +1,15 @@ /** * Claude CLI Tools Configuration Manager - * Manages .claude/cli-tools.json (tools) and .claude/cli-settings.json (settings) + * Manages cli-tools.json (tools) and cli-settings.json (settings) * - * Configuration Strategy: - * - READ: Project → Global → Default (fallback chain) - * - CREATE: Always in ~/.claude/ (global user-level config) - * - SAVE: Based on source (project config saves to project, others to global) + * Configuration Strategy (GLOBAL ONLY): + * - READ: Global → Default (no project-level configs) + * - CREATE/SAVE: Always in ~/.claude/ (global user-level config) * - * Read priority: - * 1. Project workspace: {projectDir}/.claude/ (if exists) - * 2. Global: ~/.claude/ (fallback) + * Config location: ~/.claude/cli-tools.json + * Settings location: ~/.claude/cli-settings.json + * + * Note: Project-level configs are NOT used - all config is user-level. */ import * as fs from 'fs'; import * as path from 'path'; @@ -177,50 +177,35 @@ function getGlobalSettingsPath(): string { } /** - * Resolve config path with fallback: - * 1. Project: {projectDir}/.claude/cli-tools.json - * 2. Global: ~/.claude/cli-tools.json - * Returns { path, source } where source is 'project' | 'global' | 'default' + * Resolve config path - GLOBAL ONLY + * Config is user-level, stored only in ~/.claude/cli-tools.json + * Returns { path, source } where source is 'global' | 'default' */ function resolveConfigPath(projectDir: string): { path: string; source: 'project' | 'global' | 'default' } { - const projectPath = getProjectConfigPath(projectDir); - if (fs.existsSync(projectPath)) { - return { path: projectPath, source: 'project' }; - } - const globalPath = getGlobalConfigPath(); if (fs.existsSync(globalPath)) { return { path: globalPath, source: 'global' }; } - return { path: projectPath, source: 'default' }; + // Return global path for default (will be created there) + return { path: globalPath, source: 'default' }; } /** - * Resolve settings path with fallback: - * 1. Project: {projectDir}/.claude/cli-settings.json - * 2. Global: ~/.claude/cli-settings.json + * Resolve settings path - GLOBAL ONLY + * Settings are user-level, stored only in ~/.claude/cli-settings.json */ function resolveSettingsPath(projectDir: string): { path: string; source: 'project' | 'global' | 'default' } { - const projectPath = getProjectSettingsPath(projectDir); - if (fs.existsSync(projectPath)) { - return { path: projectPath, source: 'project' }; - } - const globalPath = getGlobalSettingsPath(); if (fs.existsSync(globalPath)) { return { path: globalPath, source: 'global' }; } - return { path: projectPath, source: 'default' }; + // Return global path for default (will be created there) + return { path: globalPath, source: 'default' }; } -function ensureClaudeDir(projectDir: string): void { - const claudeDir = path.join(projectDir, '.claude'); - if (!fs.existsSync(claudeDir)) { - fs.mkdirSync(claudeDir, { recursive: true }); - } -} +// NOTE: ensureClaudeDir removed - config should only be in ~/.claude/, not project directory // ========== Main Functions ========== @@ -336,10 +321,8 @@ export function ensureClaudeCliTools(projectDir: string, createInProject: boolea } /** - * Load CLI tools configuration with fallback: - * 1. Project: {projectDir}/.claude/cli-tools.json - * 2. Global: ~/.claude/cli-tools.json - * 3. Default config + * 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 */ @@ -398,27 +381,18 @@ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & { } /** - * Save CLI tools configuration - * - If config was loaded from project, saves to project - * - Otherwise saves to global ~/.claude/cli-tools.json + * Save CLI tools configuration to global ~/.claude/cli-tools.json + * Always saves to global directory (user-level config) */ export function saveClaudeCliTools(projectDir: string, config: ClaudeCliToolsConfig & { _source?: string }): void { const { _source, ...configToSave } = config; - // Determine save location based on source - let configPath: string; - if (_source === 'project') { - // Config was loaded from project, save back to project - ensureClaudeDir(projectDir); - configPath = getProjectConfigPath(projectDir); - } else { - // Default: save to global directory - const globalDir = path.join(os.homedir(), '.claude'); - if (!fs.existsSync(globalDir)) { - fs.mkdirSync(globalDir, { recursive: true }); - } - configPath = getGlobalConfigPath(); + // Always save to global directory + const globalDir = path.join(os.homedir(), '.claude'); + if (!fs.existsSync(globalDir)) { + fs.mkdirSync(globalDir, { recursive: true }); } + const configPath = getGlobalConfigPath(); try { fs.writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); @@ -430,10 +404,8 @@ export function saveClaudeCliTools(projectDir: string, config: ClaudeCliToolsCon } /** - * Load CLI settings configuration with fallback: - * 1. Project: {projectDir}/.claude/cli-settings.json - * 2. Global: ~/.claude/cli-settings.json - * 3. Default settings + * Load CLI settings configuration from global ~/.claude/cli-settings.json + * Falls back to default settings if not found. */ export function loadClaudeCliSettings(projectDir: string): ClaudeCliSettingsConfig & { _source?: string } { const resolved = resolveSettingsPath(projectDir); @@ -469,14 +441,19 @@ export function loadClaudeCliSettings(projectDir: string): ClaudeCliSettingsConf } /** - * Save CLI settings configuration to project .claude/cli-settings.json + * Save CLI settings configuration to global ~/.claude/cli-settings.json + * Always saves to global directory (user-level config) */ export function saveClaudeCliSettings(projectDir: string, config: ClaudeCliSettingsConfig & { _source?: string }): void { - ensureClaudeDir(projectDir); - const settingsPath = getProjectSettingsPath(projectDir); - const { _source, ...configToSave } = config; + // Always save to global directory + const globalDir = path.join(os.homedir(), '.claude'); + if (!fs.existsSync(globalDir)) { + fs.mkdirSync(globalDir, { recursive: true }); + } + const settingsPath = getGlobalSettingsPath(); + try { fs.writeFileSync(settingsPath, JSON.stringify(configToSave, null, 2), 'utf-8'); console.log(`[claude-cli-tools] Saved settings to: ${settingsPath}`); diff --git a/ccw/src/tools/cli-executor-core.ts b/ccw/src/tools/cli-executor-core.ts index 1035a394..03a6a184 100644 --- a/ccw/src/tools/cli-executor-core.ts +++ b/ccw/src/tools/cli-executor-core.ts @@ -859,9 +859,28 @@ export { /** * Get status of all CLI tools + * Dynamically reads tools from config file */ export async function getCliToolsStatus(): Promise> { - const tools = ['gemini', 'qwen', 'codex', 'claude', 'opencode']; + // Default built-in tools + const builtInTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode']; + + // Try to get tools from config + let tools = builtInTools; + try { + // Dynamic import to avoid circular dependencies + const { loadClaudeCliTools } = await import('./claude-cli-tools.js'); + const config = loadClaudeCliTools(configBaseDir); + if (config.tools && typeof config.tools === 'object') { + // Merge built-in tools with config tools to ensure all are checked + const configTools = Object.keys(config.tools); + tools = [...new Set([...builtInTools, ...configTools])]; + } + } catch (e) { + // Fallback to built-in tools if config load fails + debugLog('cli-executor', `Using built-in tools (config load failed: ${(e as Error).message})`); + } + const results: Record = {}; await Promise.all(tools.map(async (tool) => { diff --git a/ccw/src/utils/shell-escape.ts b/ccw/src/utils/shell-escape.ts index 4f52282d..e2f751f4 100644 --- a/ccw/src/utils/shell-escape.ts +++ b/ccw/src/utils/shell-escape.ts @@ -10,8 +10,12 @@ const WINDOWS_METACHARS = /[&|<>()%!"]/g; export function escapeWindowsArg(arg: string): string { if (arg === '') return '""'; + // Normalize newlines to spaces to prevent cmd.exe from + // misinterpreting multiline arguments (breaks argument parsing) + let sanitizedArg = arg.replace(/\r?\n/g, ' '); + // Escape caret first to avoid double-escaping when prefixing other metachars. - let escaped = arg.replace(/\^/g, '^^'); + let escaped = sanitizedArg.replace(/\^/g, '^^'); // Escape cmd.exe metacharacters with caret. escaped = escaped.replace(WINDOWS_METACHARS, '^$&');