From 27273405f73bf828f80abe9f97fe0502697a7bc4 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Mon, 8 Dec 2025 10:28:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CCW=20Dashboard=20=E5=A2=9E=E5=BC=BA=20?= =?UTF-8?q?-=20=E5=81=9C=E6=AD=A2=E5=91=BD=E4=BB=A4=E3=80=81=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E4=BF=AE=E5=A4=8D=E5=92=8CMCP=E5=A4=9A?= =?UTF-8?q?=E6=BA=90=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ccw stop 命令支持优雅停止和强制终止 (--force) - 修复 ccw view 服务器检测时浏览器无法打开的问题 - MCP 配置现在从多个源读取: - ~/.claude.json (项目级) - ~/.claude/settings.json 和 settings.local.json (全局) - 各工作空间的 .claude/settings.json (工作空间级) - 新增全局 MCP 服务器显示区域 - 修复路径选择模态框样式问题 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ccw/src/cli.js | 9 ++ ccw/src/commands/stop.js | 101 ++++++++++++++++++ ccw/src/core/server.js | 101 ++++++++++++++++-- .../dashboard-js/components/mcp-manager.js | 14 ++- .../dashboard-js/views/mcp-manager.js | 77 ++++++++++++- ccw/src/templates/dashboard.css | 73 +++++++++++++ ccw/src/utils/browser-launcher.js | 19 +++- package-lock.json | 4 +- 8 files changed, 377 insertions(+), 21 deletions(-) create mode 100644 ccw/src/commands/stop.js diff --git a/ccw/src/cli.js b/ccw/src/cli.js index 77050833..4f053275 100644 --- a/ccw/src/cli.js +++ b/ccw/src/cli.js @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { viewCommand } from './commands/view.js'; import { serveCommand } from './commands/serve.js'; +import { stopCommand } from './commands/stop.js'; import { installCommand } from './commands/install.js'; import { uninstallCommand } from './commands/uninstall.js'; import { upgradeCommand } from './commands/upgrade.js'; @@ -68,6 +69,14 @@ export function run(argv) { .option('--no-browser', 'Start server without opening browser') .action(serveCommand); + // Stop command + program + .command('stop') + .description('Stop the running CCW dashboard server') + .option('--port ', 'Server port', '3456') + .option('-f, --force', 'Force kill process on the port') + .action(stopCommand); + // Install command program .command('install') diff --git a/ccw/src/commands/stop.js b/ccw/src/commands/stop.js new file mode 100644 index 00000000..81a15390 --- /dev/null +++ b/ccw/src/commands/stop.js @@ -0,0 +1,101 @@ +import chalk from 'chalk'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +/** + * Find process using a specific port (Windows) + * @param {number} port - Port number + * @returns {Promise} PID or null + */ +async function findProcessOnPort(port) { + try { + const { stdout } = await execAsync(`netstat -ano | findstr :${port} | findstr LISTENING`); + const lines = stdout.trim().split('\n'); + if (lines.length > 0) { + const parts = lines[0].trim().split(/\s+/); + return parts[parts.length - 1]; // PID is the last column + } + } catch { + // No process found + } + return null; +} + +/** + * Kill process by PID (Windows) + * @param {string} pid - Process ID + * @returns {Promise} Success status + */ +async function killProcess(pid) { + try { + await execAsync(`taskkill /PID ${pid} /F`); + return true; + } catch { + return false; + } +} + +/** + * Stop command handler - stops the running CCW dashboard server + * @param {Object} options - Command options + */ +export async function stopCommand(options) { + const port = options.port || 3456; + const force = options.force || false; + + console.log(chalk.blue.bold('\n CCW Dashboard\n')); + console.log(chalk.gray(` Checking server on port ${port}...`)); + + try { + // Try graceful shutdown via API first + const healthCheck = await fetch(`http://localhost:${port}/api/health`, { + signal: AbortSignal.timeout(2000) + }).catch(() => null); + + if (healthCheck && healthCheck.ok) { + // CCW server is running - send shutdown signal + console.log(chalk.cyan(' CCW server found, sending shutdown signal...')); + + await fetch(`http://localhost:${port}/api/shutdown`, { + method: 'POST', + signal: AbortSignal.timeout(5000) + }).catch(() => null); + + // Wait a moment for shutdown + await new Promise(resolve => setTimeout(resolve, 500)); + + console.log(chalk.green.bold('\n Server stopped successfully!\n')); + return; + } + + // No CCW server responding, check if port is in use + const pid = await findProcessOnPort(port); + + if (!pid) { + console.log(chalk.yellow(` No server running on port ${port}\n`)); + return; + } + + // Port is in use by another process + console.log(chalk.yellow(` Port ${port} is in use by process PID: ${pid}`)); + + if (force) { + console.log(chalk.cyan(' Force killing process...')); + const killed = await killProcess(pid); + + if (killed) { + console.log(chalk.green.bold('\n Process killed successfully!\n')); + } else { + console.log(chalk.red('\n Failed to kill process. Try running as administrator.\n')); + } + } else { + console.log(chalk.gray(`\n This is not a CCW server. Use --force to kill it:`)); + console.log(chalk.white(` ccw stop --force\n`)); + } + + } catch (err) { + console.error(chalk.red(`\n Error: ${err.message}\n`)); + } +} diff --git a/ccw/src/core/server.js b/ccw/src/core/server.js index cc5989a6..9bb39e7a 100644 --- a/ccw/src/core/server.js +++ b/ccw/src/core/server.js @@ -8,8 +8,11 @@ import { scanSessions } from './session-scanner.js'; import { aggregateData } from './data-aggregator.js'; import { resolvePath, getRecentPaths, trackRecentPath, removeRecentPath, normalizePathForDisplay, getWorkflowDir } from '../utils/path-resolver.js'; -// Claude config file path +// Claude config file paths const CLAUDE_CONFIG_PATH = join(homedir(), '.claude.json'); +const CLAUDE_SETTINGS_DIR = join(homedir(), '.claude'); +const CLAUDE_GLOBAL_SETTINGS = join(CLAUDE_SETTINGS_DIR, 'settings.json'); +const CLAUDE_GLOBAL_SETTINGS_LOCAL = join(CLAUDE_SETTINGS_DIR, 'settings.local.json'); // WebSocket clients for real-time notifications const wsClients = new Set(); @@ -160,6 +163,24 @@ export async function startServer(options = {}) { return; } + // API: Shutdown server (for ccw stop command) + if (pathname === '/api/shutdown' && req.method === 'POST') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'shutting_down' })); + + // Graceful shutdown + console.log('\n Received shutdown signal...'); + setTimeout(() => { + server.close(() => { + console.log(' Server stopped.\n'); + process.exit(0); + }); + // Force exit after 3 seconds if graceful shutdown fails + setTimeout(() => process.exit(0), 3000); + }, 100); + return; + } + // API: Remove a recent path if (pathname === '/api/remove-recent-path' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { @@ -1006,22 +1027,82 @@ async function loadRecentPaths() { // ======================================== /** - * Get MCP configuration from .claude.json + * Safely read and parse JSON file + * @param {string} filePath + * @returns {Object|null} + */ +function safeReadJson(filePath) { + try { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath, 'utf8'); + return JSON.parse(content); + } catch { + return null; + } +} + +/** + * Get MCP servers from a settings file + * @param {string} filePath + * @returns {Object} mcpServers object or empty object + */ +function getMcpServersFromSettings(filePath) { + const config = safeReadJson(filePath); + if (!config) return {}; + return config.mcpServers || {}; +} + +/** + * Get MCP configuration from multiple sources: + * 1. ~/.claude.json (project-level MCP servers) + * 2. ~/.claude/settings.json and settings.local.json (global MCP servers) + * 3. Each workspace's .claude/settings.json and settings.local.json * @returns {Object} */ function getMcpConfig() { try { - if (!existsSync(CLAUDE_CONFIG_PATH)) { - return { projects: {} }; + const result = { projects: {}, globalServers: {} }; + + // 1. Read from ~/.claude.json (primary source for project MCP) + if (existsSync(CLAUDE_CONFIG_PATH)) { + const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); + const config = JSON.parse(content); + result.projects = config.projects || {}; } - const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); - const config = JSON.parse(content); - return { - projects: config.projects || {} - }; + + // 2. Read global MCP servers from ~/.claude/settings.json and settings.local.json + const globalSettings = getMcpServersFromSettings(CLAUDE_GLOBAL_SETTINGS); + const globalSettingsLocal = getMcpServersFromSettings(CLAUDE_GLOBAL_SETTINGS_LOCAL); + result.globalServers = { ...globalSettings, ...globalSettingsLocal }; + + // 3. For each project, also check .claude/settings.json and settings.local.json + for (const projectPath of Object.keys(result.projects)) { + const projectClaudeDir = join(projectPath, '.claude'); + const projectSettings = join(projectClaudeDir, 'settings.json'); + const projectSettingsLocal = join(projectClaudeDir, 'settings.local.json'); + + // Merge MCP servers from workspace settings into project config + const workspaceServers = { + ...getMcpServersFromSettings(projectSettings), + ...getMcpServersFromSettings(projectSettingsLocal) + }; + + if (Object.keys(workspaceServers).length > 0) { + // Merge workspace servers with existing project servers (workspace takes precedence) + result.projects[projectPath] = { + ...result.projects[projectPath], + mcpServers: { + ...(result.projects[projectPath]?.mcpServers || {}), + ...workspaceServers + } + }; + } + } + + return result; } catch (error) { console.error('Error reading MCP config:', error); - return { projects: {}, error: error.message }; + return { projects: {}, globalServers: {}, error: error.message }; } } diff --git a/ccw/src/templates/dashboard-js/components/mcp-manager.js b/ccw/src/templates/dashboard-js/components/mcp-manager.js index 4e5ba733..cb046d6d 100644 --- a/ccw/src/templates/dashboard-js/components/mcp-manager.js +++ b/ccw/src/templates/dashboard-js/components/mcp-manager.js @@ -4,6 +4,7 @@ // ========== MCP State ========== let mcpConfig = null; let mcpAllProjects = {}; +let mcpGlobalServers = {}; let mcpCurrentProjectServers = {}; let mcpCreateMode = 'form'; // 'form' or 'json' @@ -31,6 +32,7 @@ async function loadMcpConfig() { const data = await response.json(); mcpConfig = data; mcpAllProjects = data.projects || {}; + mcpGlobalServers = data.globalServers || {}; // Get current project servers const currentPath = projectPath.replace(/\//g, '\\'); @@ -150,6 +152,15 @@ function updateMcpBadge() { function getAllAvailableMcpServers() { const allServers = {}; + // Collect global servers first + for (const [name, serverConfig] of Object.entries(mcpGlobalServers)) { + allServers[name] = { + config: serverConfig, + usedIn: [], + isGlobal: true + }; + } + // Collect servers from all projects for (const [path, config] of Object.entries(mcpAllProjects)) { const servers = config.mcpServers || {}; @@ -157,7 +168,8 @@ function getAllAvailableMcpServers() { if (!allServers[name]) { allServers[name] = { config: serverConfig, - usedIn: [] + usedIn: [], + isGlobal: false }; } allServers[name].usedIn.push(path); diff --git a/ccw/src/templates/dashboard-js/views/mcp-manager.js b/ccw/src/templates/dashboard-js/views/mcp-manager.js index 2363c029..290a481a 100644 --- a/ccw/src/templates/dashboard-js/views/mcp-manager.js +++ b/ccw/src/templates/dashboard-js/views/mcp-manager.js @@ -26,8 +26,12 @@ async function renderMcpManager() { // Separate current project servers and available servers const currentProjectServerNames = Object.keys(projectServers); - const otherAvailableServers = Object.entries(allAvailableServers) + + // Separate global servers and project servers that are not in current project + const globalServerEntries = Object.entries(mcpGlobalServers) .filter(([name]) => !currentProjectServerNames.includes(name)); + const otherProjectServers = Object.entries(allAvailableServers) + .filter(([name, info]) => !currentProjectServerNames.includes(name) && !info.isGlobal); container.innerHTML = `
@@ -61,20 +65,39 @@ async function renderMcpManager() { `}
+ + ${globalServerEntries.length > 0 ? ` +
+
+
+ 🌐 +

Global MCP Servers

+
+ ${globalServerEntries.length} servers from ~/.claude/settings +
+ +
+ ${globalServerEntries.map(([serverName, serverConfig]) => { + return renderGlobalServerCard(serverName, serverConfig); + }).join('')} +
+
+ ` : ''} +

Available from Other Projects

- ${otherAvailableServers.length} servers available + ${otherProjectServers.length} servers available
- ${otherAvailableServers.length === 0 ? ` + ${otherProjectServers.length === 0 ? `

No additional MCP servers found in other projects

` : `
- ${otherAvailableServers.map(([serverName, serverInfo]) => { + ${otherProjectServers.map(([serverName, serverInfo]) => { return renderAvailableServerCard(serverName, serverInfo); }).join('')}
@@ -240,6 +263,52 @@ function renderAvailableServerCard(serverName, serverInfo) { `; } +function renderGlobalServerCard(serverName, serverConfig) { + const command = serverConfig.command || 'N/A'; + const args = serverConfig.args || []; + const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0; + + return ` +
+
+
+ 🌐 +

${escapeHtml(serverName)}

+ Global +
+ +
+ +
+
+ cmd + ${escapeHtml(command)} +
+ ${args.length > 0 ? ` +
+ args + ${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''} +
+ ` : ''} + ${hasEnv ? ` +
+ env + ${Object.keys(serverConfig.env).length} variables +
+ ` : ''} +
+ Available to all projects from ~/.claude/settings +
+
+
+ `; +} + function attachMcpEventListeners() { // Toggle switches document.querySelectorAll('.mcp-server-card input[data-action="toggle"]').forEach(input => { diff --git a/ccw/src/templates/dashboard.css b/ccw/src/templates/dashboard.css index 146a8145..78a68891 100644 --- a/ccw/src/templates/dashboard.css +++ b/ccw/src/templates/dashboard.css @@ -8112,3 +8112,76 @@ code.ctx-meta-chip-value { opacity: 0.5; cursor: not-allowed; } + +/* Path Input Group */ +.path-input-group { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.path-input-group label { + font-size: 0.875rem; + color: hsl(var(--muted-foreground)); + white-space: nowrap; +} + +.path-input-group input { + flex: 1; + min-width: 200px; + padding: 0.625rem 0.875rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + font-size: 0.875rem; + font-family: var(--font-mono); + color: hsl(var(--foreground)); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.path-input-group input:focus { + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1); +} + +.path-input-group input::placeholder { + color: hsl(var(--muted-foreground)); +} + +.path-go-btn { + padding: 0.625rem 1.25rem; + background: hsl(var(--primary)); + color: white; + border: none; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.path-go-btn:hover { + background: hsl(var(--primary) / 0.9); + transform: translateY(-1px); +} + +.path-go-btn:active { + transform: translateY(0); +} + +/* Selected Folder Display */ +.selected-folder { + padding: 0.75rem 1rem; + background: hsl(var(--muted)); + border-radius: 0.5rem; + margin-bottom: 0.75rem; +} + +.selected-folder strong { + font-size: 1rem; + color: hsl(var(--foreground)); + font-family: var(--font-mono); +} diff --git a/ccw/src/utils/browser-launcher.js b/ccw/src/utils/browser-launcher.js index ba2a9d05..531c9bec 100644 --- a/ccw/src/utils/browser-launcher.js +++ b/ccw/src/utils/browser-launcher.js @@ -3,13 +3,24 @@ import { platform } from 'os'; import { resolve } from 'path'; /** - * Launch a file in the default browser + * Launch a URL or file in the default browser * Cross-platform compatible (Windows/macOS/Linux) - * @param {string} filePath - Path to HTML file + * @param {string} urlOrPath - HTTP URL or path to HTML file * @returns {Promise} */ -export async function launchBrowser(filePath) { - const absolutePath = resolve(filePath); +export async function launchBrowser(urlOrPath) { + // Check if it's already a URL (http:// or https://) + if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) { + try { + await open(urlOrPath); + return; + } catch (error) { + throw new Error(`Failed to open browser: ${error.message}`); + } + } + + // It's a file path - convert to file:// URL + const absolutePath = resolve(urlOrPath); // Construct file:// URL based on platform let url; diff --git a/package-lock.json b/package-lock.json index 30cabfe4..54855670 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-workflow", - "version": "6.0.3", + "version": "6.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-workflow", - "version": "6.0.3", + "version": "6.0.4", "license": "MIT", "dependencies": { "boxen": "^7.1.0",