From b81d1039c5a3d7e36bcd85f7f6731f18828b7d47 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Thu, 11 Dec 2025 11:05:57 +0800 Subject: [PATCH] feat(cli): Add CLI Manager with status and history components - Implemented CLI History Component to display execution history with filtering and search capabilities. - Created CLI Status Component to show availability of CLI tools and allow setting a default tool. - Enhanced notifications to handle CLI execution events. - Integrated CLI Manager view to combine status and history panels for better user experience. - Developed CLI Executor Tool for unified execution of external CLI tools (Gemini, Qwen, Codex) with streaming output. - Added functionality to save and retrieve CLI execution history. - Updated dashboard HTML to include navigation for CLI tools management. --- ccw/src/cli.js | 16 + ccw/src/commands/cli.js | 245 +++++++++ ccw/src/commands/install.js | 10 - ccw/src/commands/upgrade.js | 9 - ccw/src/core/server.js | 126 +++++ ccw/src/templates/dashboard-css/10-cli.css | 509 ++++++++++++++++++ .../dashboard-js/components/cli-history.js | 200 +++++++ .../dashboard-js/components/cli-status.js | 101 ++++ .../dashboard-js/components/notifications.js | 25 + ccw/src/templates/dashboard-js/main.js | 2 + .../dashboard-js/views/cli-manager.js | 282 ++++++++++ ccw/src/templates/dashboard.html | 15 + ccw/src/tools/cli-executor.js | 491 +++++++++++++++++ ccw/src/tools/index.js | 2 + 14 files changed, 2014 insertions(+), 19 deletions(-) create mode 100644 ccw/src/commands/cli.js create mode 100644 ccw/src/templates/dashboard-css/10-cli.css create mode 100644 ccw/src/templates/dashboard-js/components/cli-history.js create mode 100644 ccw/src/templates/dashboard-js/components/cli-status.js create mode 100644 ccw/src/templates/dashboard-js/views/cli-manager.js create mode 100644 ccw/src/tools/cli-executor.js diff --git a/ccw/src/cli.js b/ccw/src/cli.js index de5c5fe5..26e5456d 100644 --- a/ccw/src/cli.js +++ b/ccw/src/cli.js @@ -8,6 +8,7 @@ import { upgradeCommand } from './commands/upgrade.js'; import { listCommand } from './commands/list.js'; import { toolCommand } from './commands/tool.js'; import { sessionCommand } from './commands/session.js'; +import { cliCommand } from './commands/cli.js'; import { readFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -133,5 +134,20 @@ export function run(argv) { .option('--no-update-status', 'Skip status update on archive') .action((subcommand, args, options) => sessionCommand(subcommand, args, options)); + // CLI command + program + .command('cli [subcommand] [args...]') + .description('Unified CLI tool executor (gemini/qwen/codex)') + .option('--tool ', 'CLI tool to use', 'gemini') + .option('--mode ', 'Execution mode: analysis, write, auto', 'analysis') + .option('--model ', 'Model override') + .option('--cd ', 'Working directory (-C for codex)') + .option('--includeDirs ', 'Additional directories (--include-directories for gemini/qwen, --add-dir for codex)') + .option('--timeout ', 'Timeout in milliseconds', '300000') + .option('--no-stream', 'Disable streaming output') + .option('--limit ', 'History limit') + .option('--status ', 'Filter by status') + .action((subcommand, args, options) => cliCommand(subcommand, args, options)); + program.parse(argv); } diff --git a/ccw/src/commands/cli.js b/ccw/src/commands/cli.js new file mode 100644 index 00000000..acb9f4d9 --- /dev/null +++ b/ccw/src/commands/cli.js @@ -0,0 +1,245 @@ +/** + * CLI Command - Unified CLI tool executor command + * Provides interface for executing Gemini, Qwen, and Codex + */ + +import chalk from 'chalk'; +import { + cliExecutorTool, + getCliToolsStatus, + getExecutionHistory, + getExecutionDetail +} from '../tools/cli-executor.js'; + +/** + * Show CLI tool status + */ +async function statusAction() { + console.log(chalk.bold.cyan('\n CLI Tools Status\n')); + + const status = await getCliToolsStatus(); + + for (const [tool, info] of Object.entries(status)) { + const statusIcon = info.available ? chalk.green('●') : chalk.red('○'); + const statusText = info.available ? chalk.green('Available') : chalk.red('Not Found'); + + console.log(` ${statusIcon} ${chalk.bold.white(tool.padEnd(10))} ${statusText}`); + if (info.available && info.path) { + console.log(chalk.gray(` ${info.path}`)); + } + } + + console.log(); +} + +/** + * Execute a CLI tool + * @param {string} prompt - Prompt to execute + * @param {Object} options - CLI options + */ +async function execAction(prompt, options) { + if (!prompt) { + console.error(chalk.red('Error: Prompt is required')); + console.error(chalk.gray('Usage: ccw cli exec "" --tool gemini')); + process.exit(1); + } + + const { tool = 'gemini', mode = 'analysis', model, cd, includeDirs, timeout, noStream } = options; + + console.log(chalk.cyan(`\n Executing ${tool} (${mode} mode)...\n`)); + + // Streaming output handler + const onOutput = noStream ? null : (chunk) => { + process.stdout.write(chunk.data); + }; + + try { + const result = await cliExecutorTool.execute({ + tool, + prompt, + mode, + model, + dir: cd, + include: includeDirs, + timeout: timeout ? parseInt(timeout, 10) : 300000, + stream: !noStream + }, onOutput); + + // If not streaming, print output now + if (noStream && result.stdout) { + console.log(result.stdout); + } + + // Print summary + console.log(); + if (result.success) { + console.log(chalk.green(` ✓ Completed in ${(result.execution.duration_ms / 1000).toFixed(1)}s`)); + } else { + console.log(chalk.red(` ✗ Failed (${result.execution.status})`)); + if (result.stderr) { + console.error(chalk.red(result.stderr)); + } + process.exit(1); + } + } catch (error) { + console.error(chalk.red(` Error: ${error.message}`)); + process.exit(1); + } +} + +/** + * Show execution history + * @param {Object} options - CLI options + */ +async function historyAction(options) { + const { limit = 20, tool, status } = options; + + console.log(chalk.bold.cyan('\n CLI Execution History\n')); + + const history = getExecutionHistory(process.cwd(), { limit: parseInt(limit, 10), tool, status }); + + if (history.executions.length === 0) { + console.log(chalk.gray(' No executions found.\n')); + return; + } + + console.log(chalk.gray(` Total executions: ${history.total}\n`)); + + for (const exec of history.executions) { + const statusIcon = exec.status === 'success' ? chalk.green('●') : + exec.status === 'timeout' ? chalk.yellow('●') : chalk.red('●'); + const duration = exec.duration_ms >= 1000 + ? `${(exec.duration_ms / 1000).toFixed(1)}s` + : `${exec.duration_ms}ms`; + + const timeAgo = getTimeAgo(new Date(exec.timestamp)); + + console.log(` ${statusIcon} ${chalk.bold.white(exec.tool.padEnd(8))} ${chalk.gray(timeAgo.padEnd(12))} ${chalk.gray(duration.padEnd(8))}`); + console.log(chalk.gray(` ${exec.prompt_preview}`)); + console.log(chalk.dim(` ID: ${exec.id}`)); + console.log(); + } +} + +/** + * Show execution detail + * @param {string} executionId - Execution ID + */ +async function detailAction(executionId) { + if (!executionId) { + console.error(chalk.red('Error: Execution ID is required')); + console.error(chalk.gray('Usage: ccw cli detail ')); + process.exit(1); + } + + const detail = getExecutionDetail(process.cwd(), executionId); + + if (!detail) { + console.error(chalk.red(`Error: Execution not found: ${executionId}`)); + process.exit(1); + } + + console.log(chalk.bold.cyan('\n Execution Detail\n')); + console.log(` ${chalk.gray('ID:')} ${detail.id}`); + console.log(` ${chalk.gray('Tool:')} ${detail.tool}`); + console.log(` ${chalk.gray('Model:')} ${detail.model}`); + console.log(` ${chalk.gray('Mode:')} ${detail.mode}`); + console.log(` ${chalk.gray('Status:')} ${detail.status}`); + console.log(` ${chalk.gray('Duration:')} ${detail.duration_ms}ms`); + console.log(` ${chalk.gray('Timestamp:')} ${detail.timestamp}`); + + console.log(chalk.bold.cyan('\n Prompt:\n')); + console.log(chalk.gray(' ' + detail.prompt.split('\n').join('\n '))); + + if (detail.output.stdout) { + console.log(chalk.bold.cyan('\n Output:\n')); + console.log(detail.output.stdout); + } + + if (detail.output.stderr) { + console.log(chalk.bold.red('\n Errors:\n')); + console.log(detail.output.stderr); + } + + if (detail.output.truncated) { + console.log(chalk.yellow('\n Note: Output was truncated due to size.')); + } + + console.log(); +} + +/** + * Get human-readable time ago string + * @param {Date} date + * @returns {string} + */ +function getTimeAgo(date) { + const seconds = Math.floor((new Date() - date) / 1000); + + if (seconds < 60) return 'just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; + return date.toLocaleDateString(); +} + +/** + * CLI command entry point + * @param {string} subcommand - Subcommand (status, exec, history, detail) + * @param {string[]} args - Arguments array + * @param {Object} options - CLI options + */ +export async function cliCommand(subcommand, args, options) { + const argsArray = Array.isArray(args) ? args : (args ? [args] : []); + + switch (subcommand) { + case 'status': + await statusAction(); + break; + + case 'exec': + await execAction(argsArray[0], options); + break; + + case 'history': + await historyAction(options); + break; + + case 'detail': + await detailAction(argsArray[0]); + break; + + default: + console.log(chalk.bold.cyan('\n CCW CLI Tool Executor\n')); + console.log(' Unified interface for Gemini, Qwen, and Codex CLI tools.\n'); + console.log(' Subcommands:'); + console.log(chalk.gray(' status Check CLI tools availability')); + console.log(chalk.gray(' exec Execute a CLI tool')); + console.log(chalk.gray(' history Show execution history')); + console.log(chalk.gray(' detail Show execution detail')); + console.log(); + console.log(' Exec Options:'); + console.log(chalk.gray(' --tool Tool to use: gemini, qwen, codex (default: gemini)')); + console.log(chalk.gray(' --mode Mode: analysis, write, auto (default: analysis)')); + console.log(chalk.gray(' --model Model override')); + console.log(chalk.gray(' --cd Working directory (-C for codex)')); + console.log(chalk.gray(' --includeDirs Additional directories (comma-separated)')); + console.log(chalk.gray(' → gemini/qwen: --include-directories')); + console.log(chalk.gray(' → codex: --add-dir')); + console.log(chalk.gray(' --timeout Timeout in milliseconds (default: 300000)')); + console.log(chalk.gray(' --no-stream Disable streaming output')); + console.log(); + console.log(' History Options:'); + console.log(chalk.gray(' --limit Number of results (default: 20)')); + console.log(chalk.gray(' --tool Filter by tool')); + console.log(chalk.gray(' --status Filter by status')); + console.log(); + console.log(' Examples:'); + console.log(chalk.gray(' ccw cli status')); + console.log(chalk.gray(' ccw cli exec "Analyze the auth module" --tool gemini')); + console.log(chalk.gray(' ccw cli exec "Analyze with context" --tool gemini --includeDirs ../shared,../types')); + console.log(chalk.gray(' ccw cli exec "Implement feature" --tool codex --mode auto --includeDirs ./lib')); + console.log(chalk.gray(' ccw cli history --tool gemini --limit 10')); + console.log(); + } +} diff --git a/ccw/src/commands/install.js b/ccw/src/commands/install.js index bed8a58b..f92626d3 100644 --- a/ccw/src/commands/install.js +++ b/ccw/src/commands/install.js @@ -133,16 +133,6 @@ export async function installCommand(options) { totalDirs += directories; } - // Copy CLAUDE.md to .claude directory - const claudeMdSrc = join(sourceDir, 'CLAUDE.md'); - const claudeMdDest = join(installPath, '.claude', 'CLAUDE.md'); - if (existsSync(claudeMdSrc) && existsSync(dirname(claudeMdDest))) { - spinner.text = 'Installing CLAUDE.md...'; - copyFileSync(claudeMdSrc, claudeMdDest); - addFileEntry(manifest, claudeMdDest); - totalFiles++; - } - // Create version.json const versionPath = join(installPath, '.claude', 'version.json'); if (existsSync(dirname(versionPath))) { diff --git a/ccw/src/commands/upgrade.js b/ccw/src/commands/upgrade.js index 25102840..fc11fbb3 100644 --- a/ccw/src/commands/upgrade.js +++ b/ccw/src/commands/upgrade.js @@ -234,15 +234,6 @@ async function performUpgrade(manifest, sourceDir, version) { totalDirs += directories; } - // Copy CLAUDE.md to .claude directory - const claudeMdSrc = join(sourceDir, 'CLAUDE.md'); - const claudeMdDest = join(installPath, '.claude', 'CLAUDE.md'); - if (existsSync(claudeMdSrc) && existsSync(dirname(claudeMdDest))) { - copyFileSync(claudeMdSrc, claudeMdDest); - addFileEntry(newManifest, claudeMdDest); - totalFiles++; - } - // Update version.json const versionPath = join(installPath, '.claude', 'version.json'); if (existsSync(dirname(versionPath))) { diff --git a/ccw/src/core/server.js b/ccw/src/core/server.js index c2a89d46..84cca198 100644 --- a/ccw/src/core/server.js +++ b/ccw/src/core/server.js @@ -7,6 +7,7 @@ import { createHash } from 'crypto'; import { scanSessions } from './session-scanner.js'; import { aggregateData } from './data-aggregator.js'; import { resolvePath, getRecentPaths, trackRecentPath, removeRecentPath, normalizePathForDisplay, getWorkflowDir } from '../utils/path-resolver.js'; +import { getCliToolsStatus, getExecutionHistory, getExecutionDetail, executeCliTool } from '../tools/cli-executor.js'; // Claude config file paths const CLAUDE_CONFIG_PATH = join(homedir(), '.claude.json'); @@ -89,6 +90,8 @@ const MODULE_FILES = [ 'components/version-check.js', 'components/mcp-manager.js', 'components/hook-manager.js', + 'components/cli-status.js', + 'components/cli-history.js', 'components/_exp_helpers.js', 'components/tabs-other.js', 'components/tabs-context.js', @@ -105,6 +108,7 @@ const MODULE_FILES = [ 'views/fix-session.js', 'views/mcp-manager.js', 'views/hook-manager.js', + 'views/cli-manager.js', 'views/explorer.js', 'main.js' ]; @@ -436,6 +440,128 @@ export async function startServer(options = {}) { return; } + // API: CLI Tools Status + if (pathname === '/api/cli/status') { + const status = await getCliToolsStatus(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(status)); + return; + } + + // API: CLI Execution History + if (pathname === '/api/cli/history') { + const projectPath = url.searchParams.get('path') || initialPath; + const limit = parseInt(url.searchParams.get('limit') || '50', 10); + const tool = url.searchParams.get('tool') || null; + const status = url.searchParams.get('status') || null; + + const history = getExecutionHistory(projectPath, { limit, tool, status }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(history)); + return; + } + + // API: CLI Execution Detail + if (pathname === '/api/cli/execution') { + const projectPath = url.searchParams.get('path') || initialPath; + const executionId = url.searchParams.get('id'); + + if (!executionId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Execution ID is required' })); + return; + } + + const detail = getExecutionDetail(projectPath, executionId); + if (!detail) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Execution not found' })); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(detail)); + return; + } + + // API: Execute CLI Tool + if (pathname === '/api/cli/execute' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { tool, prompt, mode, model, dir, includeDirs, timeout } = body; + + if (!tool || !prompt) { + return { error: 'tool and prompt are required', status: 400 }; + } + + // Start execution + const executionId = `${Date.now()}-${tool}`; + + // Broadcast execution started + broadcastToClients({ + type: 'CLI_EXECUTION_STARTED', + payload: { + executionId, + tool, + mode: mode || 'analysis', + timestamp: new Date().toISOString() + } + }); + + try { + // Execute with streaming output broadcast + const result = await executeCliTool({ + tool, + prompt, + mode: mode || 'analysis', + model, + dir: dir || initialPath, + includeDirs, + timeout: timeout || 300000, + stream: true + }, (chunk) => { + // Broadcast output chunks via WebSocket + broadcastToClients({ + type: 'CLI_OUTPUT', + payload: { + executionId, + chunkType: chunk.type, + data: chunk.data + } + }); + }); + + // Broadcast completion + broadcastToClients({ + type: 'CLI_EXECUTION_COMPLETED', + payload: { + executionId, + success: result.success, + status: result.execution.status, + duration_ms: result.execution.duration_ms + } + }); + + return { + success: result.success, + execution: result.execution + }; + + } catch (error) { + // Broadcast error + broadcastToClients({ + type: 'CLI_EXECUTION_ERROR', + payload: { + executionId, + error: error.message + } + }); + + return { error: error.message, status: 500 }; + } + }); + return; + } + // API: Update CLAUDE.md using CLI tools (Explorer view) if (pathname === '/api/update-claude-md' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { diff --git a/ccw/src/templates/dashboard-css/10-cli.css b/ccw/src/templates/dashboard-css/10-cli.css new file mode 100644 index 00000000..4d05695b --- /dev/null +++ b/ccw/src/templates/dashboard-css/10-cli.css @@ -0,0 +1,509 @@ +/* ======================================== + * CLI Manager Styles + * ======================================== */ + +/* Container */ +.cli-manager-container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.cli-manager-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +@media (max-width: 768px) { + .cli-manager-grid { + grid-template-columns: 1fr; + } +} + +/* Panels */ +.cli-panel { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + overflow: hidden; +} + +.cli-panel-full { + grid-column: 1 / -1; +} + +/* Status Panel */ +.cli-status-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.cli-status-header h3 { + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.cli-tools-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + padding: 1rem; +} + +.cli-tool-card { + padding: 0.75rem; + border-radius: 0.375rem; + background: hsl(var(--muted)); + text-align: center; +} + +.cli-tool-card.available { + border: 1px solid hsl(var(--success) / 0.3); +} + +.cli-tool-card.unavailable { + border: 1px solid hsl(var(--border)); + opacity: 0.7; +} + +.cli-tool-header { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.cli-tool-status { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.cli-tool-status.status-available { + background: hsl(var(--success)); +} + +.cli-tool-status.status-unavailable { + background: hsl(var(--muted-foreground)); +} + +.cli-tool-name { + font-weight: 600; + font-size: 0.875rem; + color: hsl(var(--foreground)); +} + +.cli-tool-badge { + font-size: 0.625rem; + padding: 0.125rem 0.375rem; + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-radius: 9999px; +} + +.cli-tool-info { + font-size: 0.75rem; + margin-bottom: 0.5rem; +} + +/* Execute Panel */ +.cli-execute-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.cli-execute-header h3 { + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.cli-execute-form { + padding: 1rem; +} + +.cli-execute-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.cli-form-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.cli-form-group label { + font-size: 0.75rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); +} + +.cli-select, +.cli-textarea { + padding: 0.5rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 0.875rem; +} + +.cli-textarea { + min-height: 80px; + resize: vertical; + font-family: monospace; +} + +.cli-select:focus, +.cli-textarea:focus { + outline: none; + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2); +} + +.cli-execute-actions { + display: flex; + justify-content: flex-end; + margin-top: 0.75rem; +} + +/* History Panel */ +.cli-history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.cli-history-header h3 { + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.cli-history-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.cli-tool-filter { + padding: 0.375rem 0.5rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 0.75rem; +} + +.cli-history-list { + max-height: 400px; + overflow-y: auto; +} + +.cli-history-item { + padding: 0.75rem 1rem; + border-bottom: 1px solid hsl(var(--border)); + cursor: pointer; + transition: background 0.15s ease; +} + +.cli-history-item:hover { + background: hsl(var(--hover)); +} + +.cli-history-item:last-child { + border-bottom: none; +} + +.cli-history-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.cli-tool-tag { + font-size: 0.625rem; + font-weight: 600; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + text-transform: uppercase; +} + +.cli-tool-gemini { + background: hsl(210 80% 55% / 0.15); + color: hsl(210 80% 50%); +} + +.cli-tool-qwen { + background: hsl(280 70% 55% / 0.15); + color: hsl(280 70% 50%); +} + +.cli-tool-codex { + background: hsl(142 71% 45% / 0.15); + color: hsl(142 71% 40%); +} + +.cli-history-time { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.cli-history-prompt { + font-size: 0.8125rem; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.cli-history-meta { + font-size: 0.6875rem; + margin-top: 0.25rem; +} + +/* Output Panel */ +.cli-output-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.cli-output-header h3 { + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.cli-output-status { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status-indicator.running { + background: hsl(var(--warning)); + animation: pulse 1.5s infinite; +} + +.status-indicator.success { + background: hsl(var(--success)); +} + +.status-indicator.error { + background: hsl(var(--destructive)); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.cli-output-content { + padding: 1rem; + background: hsl(var(--muted)); + font-family: monospace; + font-size: 0.75rem; + color: hsl(var(--foreground)); + max-height: 300px; + overflow-y: auto; + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; +} + +/* Detail Modal */ +.cli-detail-header { + margin-bottom: 1rem; +} + +.cli-detail-info { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.cli-detail-status { + font-size: 0.75rem; + font-weight: 500; + padding: 0.125rem 0.5rem; + border-radius: 9999px; +} + +.cli-detail-status.status-success { + background: hsl(var(--success-light)); + color: hsl(var(--success)); +} + +.cli-detail-status.status-error { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); +} + +.cli-detail-status.status-timeout { + background: hsl(var(--warning-light)); + color: hsl(var(--warning)); +} + +.cli-detail-meta { + display: flex; + gap: 1rem; + font-size: 0.75rem; +} + +.cli-detail-section { + margin-bottom: 1rem; +} + +.cli-detail-section h4 { + font-size: 0.8125rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin-bottom: 0.5rem; +} + +.cli-detail-prompt { + padding: 0.75rem; + background: hsl(var(--muted)); + border-radius: 0.375rem; + font-family: monospace; + font-size: 0.75rem; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 200px; + overflow-y: auto; +} + +.cli-detail-output { + padding: 0.75rem; + background: hsl(var(--muted)); + border-radius: 0.375rem; + font-family: monospace; + font-size: 0.75rem; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 300px; + overflow-y: auto; +} + +.cli-detail-error { + padding: 0.75rem; + background: hsl(var(--destructive) / 0.1); + border-radius: 0.375rem; + font-family: monospace; + font-size: 0.75rem; + color: hsl(var(--destructive)); + white-space: pre-wrap; + word-wrap: break-word; + max-height: 150px; + overflow-y: auto; +} + +/* Button Styles */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + border: none; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-primary { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.btn-primary:hover:not(:disabled) { + opacity: 0.9; +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-icon { + padding: 0.375rem; + background: transparent; + border: none; + color: hsl(var(--muted-foreground)); + cursor: pointer; + border-radius: 0.25rem; + transition: all 0.15s ease; +} + +.btn-icon:hover { + background: hsl(var(--hover)); + color: hsl(var(--foreground)); +} + +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + border-radius: 0.25rem; +} + +.btn-outline { + background: transparent; + border: 1px solid hsl(var(--border)); + color: hsl(var(--foreground)); +} + +.btn-outline:hover { + background: hsl(var(--hover)); +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + color: hsl(var(--muted-foreground)); +} + +.empty-state svg { + width: 2rem; + height: 2rem; + margin-bottom: 0.5rem; + opacity: 0.5; +} + +.empty-state p { + font-size: 0.875rem; +} diff --git a/ccw/src/templates/dashboard-js/components/cli-history.js b/ccw/src/templates/dashboard-js/components/cli-history.js new file mode 100644 index 00000000..e96390e2 --- /dev/null +++ b/ccw/src/templates/dashboard-js/components/cli-history.js @@ -0,0 +1,200 @@ +// CLI History Component +// Displays execution history with filtering and search + +// ========== CLI History State ========== +let cliExecutionHistory = []; +let cliHistoryFilter = null; // Filter by tool +let cliHistoryLimit = 50; + +// ========== Data Loading ========== +async function loadCliHistory(options = {}) { + try { + const { limit = cliHistoryLimit, tool = cliHistoryFilter, status = null } = options; + + let url = `/api/cli/history?path=${encodeURIComponent(projectPath)}&limit=${limit}`; + if (tool) url += `&tool=${tool}`; + if (status) url += `&status=${status}`; + + const response = await fetch(url); + if (!response.ok) throw new Error('Failed to load CLI history'); + const data = await response.json(); + cliExecutionHistory = data.executions || []; + + return data; + } catch (err) { + console.error('Failed to load CLI history:', err); + return { executions: [], total: 0, count: 0 }; + } +} + +async function loadExecutionDetail(executionId) { + try { + const url = `/api/cli/execution?path=${encodeURIComponent(projectPath)}&id=${encodeURIComponent(executionId)}`; + const response = await fetch(url); + if (!response.ok) throw new Error('Execution not found'); + return await response.json(); + } catch (err) { + console.error('Failed to load execution detail:', err); + return null; + } +} + +// ========== Rendering ========== +function renderCliHistory() { + const container = document.getElementById('cli-history-panel'); + if (!container) return; + + if (cliExecutionHistory.length === 0) { + container.innerHTML = ` +
+

Execution History

+
+ ${renderToolFilter()} + +
+
+
+ +

No executions yet

+
+ `; + + if (window.lucide) lucide.createIcons(); + return; + } + + const historyHtml = cliExecutionHistory.map(exec => { + const statusIcon = exec.status === 'success' ? 'check-circle' : + exec.status === 'timeout' ? 'clock' : 'x-circle'; + const statusClass = exec.status === 'success' ? 'text-success' : + exec.status === 'timeout' ? 'text-warning' : 'text-destructive'; + const duration = formatDuration(exec.duration_ms); + const timeAgo = getTimeAgo(new Date(exec.timestamp)); + + return ` +
+
+ ${exec.tool} + ${timeAgo} + +
+
${escapeHtml(exec.prompt_preview)}
+
+ ${duration} +
+
+ `; + }).join(''); + + container.innerHTML = ` +
+

Execution History

+
+ ${renderToolFilter()} + +
+
+
+ ${historyHtml} +
+ `; + + if (window.lucide) lucide.createIcons(); +} + +function renderToolFilter() { + const tools = ['all', 'gemini', 'qwen', 'codex']; + return ` + + `; +} + +// ========== Execution Detail Modal ========== +async function showExecutionDetail(executionId) { + const detail = await loadExecutionDetail(executionId); + if (!detail) { + showRefreshToast('Execution not found', 'error'); + return; + } + + const modalContent = ` +
+
+ ${detail.tool} + ${detail.status} + ${formatDuration(detail.duration_ms)} +
+
+ Model: ${detail.model || 'default'} + Mode: ${detail.mode} + ${new Date(detail.timestamp).toLocaleString()} +
+
+
+

Prompt

+
${escapeHtml(detail.prompt)}
+
+ ${detail.output.stdout ? ` +
+

Output

+
${escapeHtml(detail.output.stdout)}
+
+ ` : ''} + ${detail.output.stderr ? ` +
+

Errors

+
${escapeHtml(detail.output.stderr)}
+
+ ` : ''} + ${detail.output.truncated ? ` +

Output was truncated due to size.

+ ` : ''} + `; + + showModal('Execution Detail', modalContent); +} + +// ========== Actions ========== +async function filterCliHistory(tool) { + cliHistoryFilter = tool || null; + await loadCliHistory(); + renderCliHistory(); +} + +async function refreshCliHistory() { + await loadCliHistory(); + renderCliHistory(); + showRefreshToast('History refreshed', 'success'); +} + +// ========== Helpers ========== +function formatDuration(ms) { + if (ms >= 60000) { + const mins = Math.floor(ms / 60000); + const secs = Math.round((ms % 60000) / 1000); + return `${mins}m ${secs}s`; + } else if (ms >= 1000) { + return `${(ms / 1000).toFixed(1)}s`; + } + return `${ms}ms`; +} + +function getTimeAgo(date) { + const seconds = Math.floor((new Date() - date) / 1000); + + if (seconds < 60) return 'just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; + return date.toLocaleDateString(); +} diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js new file mode 100644 index 00000000..a2eae932 --- /dev/null +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -0,0 +1,101 @@ +// CLI Status Component +// Displays CLI tool availability status and allows setting default tool + +// ========== CLI State ========== +let cliToolStatus = { gemini: {}, qwen: {}, codex: {} }; +let defaultCliTool = 'gemini'; + +// ========== Initialization ========== +function initCliStatus() { + // Load CLI status on init + loadCliToolStatus(); +} + +// ========== Data Loading ========== +async function loadCliToolStatus() { + try { + const response = await fetch('/api/cli/status'); + if (!response.ok) throw new Error('Failed to load CLI status'); + const data = await response.json(); + cliToolStatus = data; + + // Update badge + updateCliBadge(); + + return data; + } catch (err) { + console.error('Failed to load CLI status:', err); + return null; + } +} + +// ========== Badge Update ========== +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); + } +} + +// ========== Rendering ========== +function renderCliStatus() { + const container = document.getElementById('cli-status-panel'); + if (!container) return; + + const tools = ['gemini', 'qwen', 'codex']; + + const toolsHtml = tools.map(tool => { + const status = cliToolStatus[tool] || {}; + const isAvailable = status.available; + const isDefault = defaultCliTool === tool; + + return ` +
+
+ + ${tool.charAt(0).toUpperCase() + tool.slice(1)} + ${isDefault ? 'Default' : ''} +
+
+ ${isAvailable + ? `Ready` + : `Not Installed` + } +
+ ${isAvailable && !isDefault + ? `` + : '' + } +
+ `; + }).join(''); + + container.innerHTML = ` +
+

CLI Tools

+ +
+
+ ${toolsHtml} +
+ `; + + // Initialize Lucide icons + if (window.lucide) { + lucide.createIcons(); + } +} + +// ========== Actions ========== +function setDefaultCliTool(tool) { + defaultCliTool = tool; + renderCliStatus(); + showRefreshToast(`Default CLI tool set to ${tool}`, 'success'); +} diff --git a/ccw/src/templates/dashboard-js/components/notifications.js b/ccw/src/templates/dashboard-js/components/notifications.js index 781f38c8..e6c5dec4 100644 --- a/ccw/src/templates/dashboard-js/components/notifications.js +++ b/ccw/src/templates/dashboard-js/components/notifications.js @@ -83,6 +83,31 @@ function handleNotification(data) { handleToolExecutionNotification(payload); break; + // CLI Tool Execution Events + case 'CLI_EXECUTION_STARTED': + if (typeof handleCliExecutionStarted === 'function') { + handleCliExecutionStarted(payload); + } + break; + + case 'CLI_OUTPUT': + if (typeof handleCliOutput === 'function') { + handleCliOutput(payload); + } + break; + + case 'CLI_EXECUTION_COMPLETED': + if (typeof handleCliExecutionCompleted === 'function') { + handleCliExecutionCompleted(payload); + } + break; + + case 'CLI_EXECUTION_ERROR': + if (typeof handleCliExecutionError === 'function') { + handleCliExecutionError(payload); + } + break; + default: console.log('[WS] Unknown notification type:', type); } diff --git a/ccw/src/templates/dashboard-js/main.js b/ccw/src/templates/dashboard-js/main.js index 59a387a9..ab23e0f3 100644 --- a/ccw/src/templates/dashboard-js/main.js +++ b/ccw/src/templates/dashboard-js/main.js @@ -15,6 +15,8 @@ document.addEventListener('DOMContentLoaded', async () => { try { initCarousel(); } catch (e) { console.error('Carousel init failed:', e); } try { initMcpManager(); } catch (e) { console.error('MCP Manager init failed:', e); } try { initHookManager(); } catch (e) { console.error('Hook Manager init failed:', e); } + try { initCliManager(); } catch (e) { console.error('CLI Manager init failed:', e); } + try { initCliStatus(); } catch (e) { console.error('CLI Status init failed:', e); } try { initGlobalNotifications(); } catch (e) { console.error('Global notifications init failed:', e); } try { initVersionCheck(); } catch (e) { console.error('Version check init failed:', e); } diff --git a/ccw/src/templates/dashboard-js/views/cli-manager.js b/ccw/src/templates/dashboard-js/views/cli-manager.js new file mode 100644 index 00000000..cc37b3c9 --- /dev/null +++ b/ccw/src/templates/dashboard-js/views/cli-manager.js @@ -0,0 +1,282 @@ +// CLI Manager View +// Main view combining CLI status and history panels + +// ========== CLI Manager State ========== +let currentCliExecution = null; +let cliExecutionOutput = ''; + +// ========== Initialization ========== +function initCliManager() { + // Initialize CLI navigation + document.querySelectorAll('.nav-item[data-view="cli-manager"]').forEach(item => { + item.addEventListener('click', () => { + setActiveNavItem(item); + currentView = 'cli-manager'; + currentFilter = null; + currentLiteType = null; + currentSessionDetailKey = null; + updateContentTitle(); + renderCliManager(); + }); + }); +} + +// ========== Rendering ========== +async function renderCliManager() { + const mainContent = document.querySelector('.main-content'); + if (!mainContent) return; + + // Load data + await Promise.all([ + loadCliToolStatus(), + loadCliHistory() + ]); + + mainContent.innerHTML = ` +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+

Execution Output

+
+ + Running... +
+
+

+      
+
+ `; + + // Render sub-panels + renderCliStatus(); + renderCliExecutePanel(); + renderCliHistory(); + + // Initialize Lucide icons + if (window.lucide) { + lucide.createIcons(); + } +} + +function renderCliExecutePanel() { + const container = document.getElementById('cli-execute-panel'); + if (!container) return; + + const tools = ['gemini', 'qwen', 'codex']; + const modes = ['analysis', 'write', 'auto']; + + container.innerHTML = ` +
+

Quick Execute

+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ `; + + if (window.lucide) lucide.createIcons(); +} + +// ========== Execution ========== +async function executeCliFromDashboard() { + const tool = document.getElementById('cli-exec-tool').value; + const mode = document.getElementById('cli-exec-mode').value; + const prompt = document.getElementById('cli-exec-prompt').value.trim(); + + if (!prompt) { + showRefreshToast('Please enter a prompt', 'error'); + return; + } + + // Show output panel + currentCliExecution = { tool, mode, prompt, startTime: Date.now() }; + cliExecutionOutput = ''; + + const outputPanel = document.getElementById('cli-output-panel'); + const outputContent = document.getElementById('cli-output-content'); + const statusIndicator = document.getElementById('cli-output-status-indicator'); + const statusText = document.getElementById('cli-output-status-text'); + + if (outputPanel) outputPanel.classList.remove('hidden'); + if (outputContent) outputContent.textContent = ''; + if (statusIndicator) { + statusIndicator.className = 'status-indicator running'; + } + if (statusText) statusText.textContent = 'Running...'; + + // Disable execute button + const execBtn = document.querySelector('.cli-execute-actions .btn-primary'); + if (execBtn) execBtn.disabled = true; + + try { + const response = await fetch('/api/cli/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tool, + mode, + prompt, + dir: projectPath + }) + }); + + const result = await response.json(); + + // Update status + if (statusIndicator) { + statusIndicator.className = `status-indicator ${result.success ? 'success' : 'error'}`; + } + if (statusText) { + const duration = formatDuration(result.execution?.duration_ms || (Date.now() - currentCliExecution.startTime)); + statusText.textContent = result.success + ? `Completed in ${duration}` + : `Failed: ${result.error || 'Unknown error'}`; + } + + // Refresh history + await loadCliHistory(); + renderCliHistory(); + + if (result.success) { + showRefreshToast('Execution completed', 'success'); + } else { + showRefreshToast(result.error || 'Execution failed', 'error'); + } + + } catch (error) { + if (statusIndicator) { + statusIndicator.className = 'status-indicator error'; + } + if (statusText) { + statusText.textContent = `Error: ${error.message}`; + } + showRefreshToast(`Execution error: ${error.message}`, 'error'); + } + + currentCliExecution = null; + + // Re-enable execute button + if (execBtn) execBtn.disabled = false; +} + +// ========== WebSocket Event Handlers ========== +function handleCliExecutionStarted(payload) { + const { executionId, tool, mode, timestamp } = payload; + currentCliExecution = { executionId, tool, mode, startTime: new Date(timestamp).getTime() }; + cliExecutionOutput = ''; + + // Show output panel if in CLI manager view + if (currentView === 'cli-manager') { + const outputPanel = document.getElementById('cli-output-panel'); + const outputContent = document.getElementById('cli-output-content'); + const statusIndicator = document.getElementById('cli-output-status-indicator'); + const statusText = document.getElementById('cli-output-status-text'); + + if (outputPanel) outputPanel.classList.remove('hidden'); + if (outputContent) outputContent.textContent = ''; + if (statusIndicator) statusIndicator.className = 'status-indicator running'; + if (statusText) statusText.textContent = `Running ${tool} (${mode})...`; + } +} + +function handleCliOutput(payload) { + const { data } = payload; + cliExecutionOutput += data; + + // Update output panel if visible + const outputContent = document.getElementById('cli-output-content'); + if (outputContent) { + outputContent.textContent = cliExecutionOutput; + // Auto-scroll to bottom + outputContent.scrollTop = outputContent.scrollHeight; + } +} + +function handleCliExecutionCompleted(payload) { + const { executionId, success, status, duration_ms } = payload; + + // Update status + const statusIndicator = document.getElementById('cli-output-status-indicator'); + const statusText = document.getElementById('cli-output-status-text'); + + if (statusIndicator) { + statusIndicator.className = `status-indicator ${success ? 'success' : 'error'}`; + } + if (statusText) { + statusText.textContent = success + ? `Completed in ${formatDuration(duration_ms)}` + : `Failed: ${status}`; + } + + currentCliExecution = null; + + // Refresh history + if (currentView === 'cli-manager') { + loadCliHistory().then(() => renderCliHistory()); + } +} + +function handleCliExecutionError(payload) { + const { executionId, error } = payload; + + const statusIndicator = document.getElementById('cli-output-status-indicator'); + const statusText = document.getElementById('cli-output-status-text'); + + if (statusIndicator) { + statusIndicator.className = 'status-indicator error'; + } + if (statusText) { + statusText.textContent = `Error: ${error}`; + } + + currentCliExecution = null; +} diff --git a/ccw/src/templates/dashboard.html b/ccw/src/templates/dashboard.html index e1e96be4..d925c146 100644 --- a/ccw/src/templates/dashboard.html +++ b/ccw/src/templates/dashboard.html @@ -398,6 +398,21 @@ + + +
+
+ + CLI Tools +
+
    + +
+
diff --git a/ccw/src/tools/cli-executor.js b/ccw/src/tools/cli-executor.js new file mode 100644 index 00000000..e5f3f1ad --- /dev/null +++ b/ccw/src/tools/cli-executor.js @@ -0,0 +1,491 @@ +/** + * CLI Executor Tool - Unified execution for external CLI tools + * Supports Gemini, Qwen, and Codex with streaming output + */ + +import { spawn } from 'child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +// CLI History storage path +const CLI_HISTORY_DIR = join(process.cwd(), '.workflow', '.cli-history'); + +/** + * Check if a CLI tool is available + * @param {string} tool - Tool name + * @returns {Promise<{available: boolean, path: string|null}>} + */ +async function checkToolAvailability(tool) { + return new Promise((resolve) => { + const isWindows = process.platform === 'win32'; + const command = isWindows ? 'where' : 'which'; + + const child = spawn(command, [tool], { + shell: isWindows, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + + child.on('close', (code) => { + if (code === 0 && stdout.trim()) { + resolve({ available: true, path: stdout.trim().split('\n')[0] }); + } else { + resolve({ available: false, path: null }); + } + }); + + child.on('error', () => { + resolve({ available: false, path: null }); + }); + + // Timeout after 5 seconds + setTimeout(() => { + child.kill(); + resolve({ available: false, path: null }); + }, 5000); + }); +} + +/** + * Get status of all CLI tools + * @returns {Promise} + */ +export async function getCliToolsStatus() { + const tools = ['gemini', 'qwen', 'codex']; + const results = {}; + + await Promise.all(tools.map(async (tool) => { + results[tool] = await checkToolAvailability(tool); + })); + + return results; +} + +/** + * Build command arguments based on tool and options + * @param {Object} params - Execution parameters + * @returns {{command: string, args: string[]}} + */ +function buildCommand(params) { + const { tool, prompt, mode = 'analysis', model, dir, include } = params; + + let command = tool; + let args = []; + + switch (tool) { + case 'gemini': + // gemini "[prompt]" [-m model] [--approval-mode yolo] [--include-directories] + // Note: Gemini CLI now uses positional prompt instead of -p flag + args.push(prompt); + if (model) { + args.push('-m', model); + } + if (mode === 'write') { + args.push('--approval-mode', 'yolo'); + } + if (include) { + args.push('--include-directories', include); + } + break; + + case 'qwen': + // qwen "[prompt]" [-m model] [--approval-mode yolo] + // Note: Qwen CLI now also uses positional prompt instead of -p flag + args.push(prompt); + if (model) { + args.push('-m', model); + } + if (mode === 'write') { + args.push('--approval-mode', 'yolo'); + } + if (include) { + args.push('--include-directories', include); + } + break; + + case 'codex': + // codex exec [OPTIONS] "[prompt]" + // Options: -C [dir], --full-auto, -s danger-full-access, --skip-git-repo-check, --add-dir + args.push('exec'); + if (dir) { + args.push('-C', dir); + } + args.push('--full-auto'); + if (mode === 'write' || mode === 'auto') { + args.push('--skip-git-repo-check', '-s', 'danger-full-access'); + } + if (model) { + args.push('-m', model); + } + if (include) { + // Codex uses --add-dir for additional directories + // Support comma-separated or single directory + const dirs = include.split(',').map(d => d.trim()).filter(d => d); + for (const addDir of dirs) { + args.push('--add-dir', addDir); + } + } + // Prompt must be last (positional argument) + args.push(prompt); + break; + + default: + throw new Error(`Unknown CLI tool: ${tool}`); + } + + return { command, args }; +} + +/** + * Ensure history directory exists + * @param {string} baseDir - Base directory for history storage + */ +function ensureHistoryDir(baseDir) { + const historyDir = join(baseDir, '.workflow', '.cli-history'); + if (!existsSync(historyDir)) { + mkdirSync(historyDir, { recursive: true }); + } + return historyDir; +} + +/** + * Load history index + * @param {string} historyDir - History directory path + * @returns {Object} + */ +function loadHistoryIndex(historyDir) { + const indexPath = join(historyDir, 'index.json'); + if (existsSync(indexPath)) { + try { + return JSON.parse(readFileSync(indexPath, 'utf8')); + } catch { + return { version: 1, total_executions: 0, executions: [] }; + } + } + return { version: 1, total_executions: 0, executions: [] }; +} + +/** + * Save execution to history + * @param {string} historyDir - History directory path + * @param {Object} execution - Execution record + */ +function saveExecution(historyDir, execution) { + // Create date-based subdirectory + const dateStr = new Date().toISOString().split('T')[0]; + const dateDir = join(historyDir, dateStr); + if (!existsSync(dateDir)) { + mkdirSync(dateDir, { recursive: true }); + } + + // Save execution record + const filename = `${execution.id}.json`; + writeFileSync(join(dateDir, filename), JSON.stringify(execution, null, 2), 'utf8'); + + // Update index + const index = loadHistoryIndex(historyDir); + index.total_executions++; + + // Add to executions (keep last 100 in index) + index.executions.unshift({ + id: execution.id, + timestamp: execution.timestamp, + tool: execution.tool, + status: execution.status, + duration_ms: execution.duration_ms, + prompt_preview: execution.prompt.substring(0, 100) + (execution.prompt.length > 100 ? '...' : '') + }); + + if (index.executions.length > 100) { + index.executions = index.executions.slice(0, 100); + } + + writeFileSync(join(historyDir, 'index.json'), JSON.stringify(index, null, 2), 'utf8'); +} + +/** + * Execute CLI tool with streaming output + * @param {Object} params - Execution parameters + * @param {Function} onOutput - Callback for output data + * @returns {Promise} + */ +async function executeCliTool(params, onOutput = null) { + const { tool, prompt, mode = 'analysis', model, cd, dir, includeDirs, include, timeout = 300000, stream = true } = params; + + // Support both parameter naming conventions (cd/includeDirs from CLI, dir/include from internal) + const workDir = cd || dir; + const includePaths = includeDirs || include; + + // Validate tool + if (!['gemini', 'qwen', 'codex'].includes(tool)) { + throw new Error(`Invalid tool: ${tool}. Must be gemini, qwen, or codex`); + } + + // Validate prompt + if (!prompt || typeof prompt !== 'string') { + throw new Error('Prompt is required and must be a string'); + } + + // Check tool availability + const toolStatus = await checkToolAvailability(tool); + if (!toolStatus.available) { + throw new Error(`CLI tool not available: ${tool}. Please ensure it is installed and in PATH.`); + } + + // Build command with resolved parameters + const { command, args } = buildCommand({ + tool, + prompt, + mode, + model, + dir: workDir, + include: includePaths + }); + + // Determine working directory + const workingDir = workDir || process.cwd(); + + // Create execution record + const executionId = `${Date.now()}-${tool}`; + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const isWindows = process.platform === 'win32'; + + // On Windows with shell:true, we need to properly quote args containing spaces + // Build the full command string for shell execution + let spawnCommand = command; + let spawnArgs = args; + let useShell = isWindows; + + if (isWindows) { + // Quote arguments containing spaces for cmd.exe + spawnArgs = args.map(arg => { + if (arg.includes(' ') || arg.includes('"')) { + // Escape existing quotes and wrap in quotes + return `"${arg.replace(/"/g, '\\"')}"`; + } + return arg; + }); + } + + const child = spawn(spawnCommand, spawnArgs, { + cwd: workingDir, + shell: useShell, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + let timedOut = false; + + // Handle stdout + child.stdout.on('data', (data) => { + const text = data.toString(); + stdout += text; + if (stream && onOutput) { + onOutput({ type: 'stdout', data: text }); + } + }); + + // Handle stderr + child.stderr.on('data', (data) => { + const text = data.toString(); + stderr += text; + if (stream && onOutput) { + onOutput({ type: 'stderr', data: text }); + } + }); + + // Handle completion + child.on('close', (code) => { + const endTime = Date.now(); + const duration = endTime - startTime; + + // Determine status + let status = 'success'; + if (timedOut) { + status = 'timeout'; + } else if (code !== 0) { + // Check if HTTP 429 but results exist (Gemini quirk) + if (stderr.includes('429') && stdout.trim()) { + status = 'success'; + } else { + status = 'error'; + } + } + + // Create execution record + const execution = { + id: executionId, + timestamp: new Date(startTime).toISOString(), + tool, + model: model || 'default', + mode, + prompt, + status, + exit_code: code, + duration_ms: duration, + output: { + stdout: stdout.substring(0, 10240), // Truncate to 10KB + stderr: stderr.substring(0, 2048), // Truncate to 2KB + truncated: stdout.length > 10240 || stderr.length > 2048 + } + }; + + // Try to save to history + try { + const historyDir = ensureHistoryDir(workingDir); + saveExecution(historyDir, execution); + } catch (err) { + // Non-fatal: continue even if history save fails + console.error('[CLI Executor] Failed to save history:', err.message); + } + + resolve({ + success: status === 'success', + execution, + stdout, + stderr + }); + }); + + // Handle errors + child.on('error', (error) => { + reject(new Error(`Failed to spawn ${tool}: ${error.message}`)); + }); + + // Timeout handling + const timeoutId = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, 5000); + }, timeout); + + child.on('close', () => { + clearTimeout(timeoutId); + }); + }); +} + +/** + * Get execution history + * @param {string} baseDir - Base directory + * @param {Object} options - Query options + * @returns {Object} + */ +export function getExecutionHistory(baseDir, options = {}) { + const { limit = 50, tool = null, status = null } = options; + + const historyDir = join(baseDir, '.workflow', '.cli-history'); + const index = loadHistoryIndex(historyDir); + + let executions = index.executions; + + // Filter by tool + if (tool) { + executions = executions.filter(e => e.tool === tool); + } + + // Filter by status + if (status) { + executions = executions.filter(e => e.status === status); + } + + // Limit results + executions = executions.slice(0, limit); + + return { + total: index.total_executions, + count: executions.length, + executions + }; +} + +/** + * Get execution detail by ID + * @param {string} baseDir - Base directory + * @param {string} executionId - Execution ID + * @returns {Object|null} + */ +export function getExecutionDetail(baseDir, executionId) { + const historyDir = join(baseDir, '.workflow', '.cli-history'); + + // Parse date from execution ID + const timestamp = parseInt(executionId.split('-')[0], 10); + const date = new Date(timestamp); + const dateStr = date.toISOString().split('T')[0]; + + const filePath = join(historyDir, dateStr, `${executionId}.json`); + + if (existsSync(filePath)) { + try { + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch { + return null; + } + } + + return null; +} + +/** + * CLI Executor Tool Definition + */ +export const cliExecutorTool = { + name: 'cli_executor', + description: `Execute external CLI tools (gemini/qwen/codex) with unified interface. +Modes: +- analysis: Read-only operations (default) +- write: File modifications allowed +- auto: Full autonomous operations (codex only)`, + parameters: { + type: 'object', + properties: { + tool: { + type: 'string', + enum: ['gemini', 'qwen', 'codex'], + description: 'CLI tool to execute' + }, + prompt: { + type: 'string', + description: 'Prompt to send to the CLI tool' + }, + mode: { + type: 'string', + enum: ['analysis', 'write', 'auto'], + description: 'Execution mode (default: analysis)', + default: 'analysis' + }, + model: { + type: 'string', + description: 'Model override (tool-specific)' + }, + cd: { + type: 'string', + description: 'Working directory for execution (-C for codex)' + }, + includeDirs: { + type: 'string', + description: 'Additional directories (comma-separated). Maps to --include-directories for gemini/qwen, --add-dir for codex' + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds (default: 300000 = 5 minutes)', + default: 300000 + } + }, + required: ['tool', 'prompt'] + }, + execute: executeCliTool +}; + +// Export for direct usage +export { executeCliTool, checkToolAvailability }; diff --git a/ccw/src/tools/index.js b/ccw/src/tools/index.js index 0e86dcc0..412412ef 100644 --- a/ccw/src/tools/index.js +++ b/ccw/src/tools/index.js @@ -15,6 +15,7 @@ import { uiInstantiatePrototypesTool } from './ui-instantiate-prototypes.js'; import { updateModuleClaudeTool } from './update-module-claude.js'; import { convertTokensToCssTool } from './convert-tokens-to-css.js'; import { sessionManagerTool } from './session-manager.js'; +import { cliExecutorTool } from './cli-executor.js'; // Tool registry - add new tools here const tools = new Map(); @@ -258,6 +259,7 @@ registerTool(uiInstantiatePrototypesTool); registerTool(updateModuleClaudeTool); registerTool(convertTokensToCssTool); registerTool(sessionManagerTool); +registerTool(cliExecutorTool); // Export for external tool registration export { registerTool };