mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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.
This commit is contained in:
@@ -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 <tool>', 'CLI tool to use', 'gemini')
|
||||
.option('--mode <mode>', 'Execution mode: analysis, write, auto', 'analysis')
|
||||
.option('--model <model>', 'Model override')
|
||||
.option('--cd <path>', 'Working directory (-C for codex)')
|
||||
.option('--includeDirs <dirs>', 'Additional directories (--include-directories for gemini/qwen, --add-dir for codex)')
|
||||
.option('--timeout <ms>', 'Timeout in milliseconds', '300000')
|
||||
.option('--no-stream', 'Disable streaming output')
|
||||
.option('--limit <n>', 'History limit')
|
||||
.option('--status <status>', 'Filter by status')
|
||||
.action((subcommand, args, options) => cliCommand(subcommand, args, options));
|
||||
|
||||
program.parse(argv);
|
||||
}
|
||||
|
||||
245
ccw/src/commands/cli.js
Normal file
245
ccw/src/commands/cli.js
Normal file
@@ -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 "<prompt>" --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 <execution-id>'));
|
||||
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 <prompt> Execute a CLI tool'));
|
||||
console.log(chalk.gray(' history Show execution history'));
|
||||
console.log(chalk.gray(' detail <id> Show execution detail'));
|
||||
console.log();
|
||||
console.log(' Exec Options:');
|
||||
console.log(chalk.gray(' --tool <tool> Tool to use: gemini, qwen, codex (default: gemini)'));
|
||||
console.log(chalk.gray(' --mode <mode> Mode: analysis, write, auto (default: analysis)'));
|
||||
console.log(chalk.gray(' --model <model> Model override'));
|
||||
console.log(chalk.gray(' --cd <path> Working directory (-C for codex)'));
|
||||
console.log(chalk.gray(' --includeDirs <dirs> Additional directories (comma-separated)'));
|
||||
console.log(chalk.gray(' → gemini/qwen: --include-directories'));
|
||||
console.log(chalk.gray(' → codex: --add-dir'));
|
||||
console.log(chalk.gray(' --timeout <ms> 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 <n> Number of results (default: 20)'));
|
||||
console.log(chalk.gray(' --tool <tool> Filter by tool'));
|
||||
console.log(chalk.gray(' --status <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();
|
||||
}
|
||||
}
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
509
ccw/src/templates/dashboard-css/10-cli.css
Normal file
509
ccw/src/templates/dashboard-css/10-cli.css
Normal file
@@ -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;
|
||||
}
|
||||
200
ccw/src/templates/dashboard-js/components/cli-history.js
Normal file
200
ccw/src/templates/dashboard-js/components/cli-history.js
Normal file
@@ -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 = `
|
||||
<div class="cli-history-header">
|
||||
<h3>Execution History</h3>
|
||||
<div class="cli-history-controls">
|
||||
${renderToolFilter()}
|
||||
<button class="btn-icon" onclick="refreshCliHistory()" title="Refresh">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<i data-lucide="terminal"></i>
|
||||
<p>No executions yet</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 `
|
||||
<div class="cli-history-item" onclick="showExecutionDetail('${exec.id}')">
|
||||
<div class="cli-history-item-header">
|
||||
<span class="cli-tool-tag cli-tool-${exec.tool}">${exec.tool}</span>
|
||||
<span class="cli-history-time">${timeAgo}</span>
|
||||
<i data-lucide="${statusIcon}" class="${statusClass}"></i>
|
||||
</div>
|
||||
<div class="cli-history-prompt">${escapeHtml(exec.prompt_preview)}</div>
|
||||
<div class="cli-history-meta">
|
||||
<span class="text-muted-foreground">${duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="cli-history-header">
|
||||
<h3>Execution History</h3>
|
||||
<div class="cli-history-controls">
|
||||
${renderToolFilter()}
|
||||
<button class="btn-icon" onclick="refreshCliHistory()" title="Refresh">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-history-list">
|
||||
${historyHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderToolFilter() {
|
||||
const tools = ['all', 'gemini', 'qwen', 'codex'];
|
||||
return `
|
||||
<select class="cli-tool-filter" onchange="filterCliHistory(this.value)">
|
||||
${tools.map(tool => `
|
||||
<option value="${tool === 'all' ? '' : tool}" ${cliHistoryFilter === (tool === 'all' ? null : tool) ? 'selected' : ''}>
|
||||
${tool === 'all' ? 'All Tools' : tool.charAt(0).toUpperCase() + tool.slice(1)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
// ========== Execution Detail Modal ==========
|
||||
async function showExecutionDetail(executionId) {
|
||||
const detail = await loadExecutionDetail(executionId);
|
||||
if (!detail) {
|
||||
showRefreshToast('Execution not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const modalContent = `
|
||||
<div class="cli-detail-header">
|
||||
<div class="cli-detail-info">
|
||||
<span class="cli-tool-tag cli-tool-${detail.tool}">${detail.tool}</span>
|
||||
<span class="cli-detail-status status-${detail.status}">${detail.status}</span>
|
||||
<span class="text-muted-foreground">${formatDuration(detail.duration_ms)}</span>
|
||||
</div>
|
||||
<div class="cli-detail-meta">
|
||||
<span class="text-muted-foreground">Model: ${detail.model || 'default'}</span>
|
||||
<span class="text-muted-foreground">Mode: ${detail.mode}</span>
|
||||
<span class="text-muted-foreground">${new Date(detail.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-detail-section">
|
||||
<h4>Prompt</h4>
|
||||
<pre class="cli-detail-prompt">${escapeHtml(detail.prompt)}</pre>
|
||||
</div>
|
||||
${detail.output.stdout ? `
|
||||
<div class="cli-detail-section">
|
||||
<h4>Output</h4>
|
||||
<pre class="cli-detail-output">${escapeHtml(detail.output.stdout)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
${detail.output.stderr ? `
|
||||
<div class="cli-detail-section">
|
||||
<h4>Errors</h4>
|
||||
<pre class="cli-detail-error">${escapeHtml(detail.output.stderr)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
${detail.output.truncated ? `
|
||||
<p class="text-warning">Output was truncated due to size.</p>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
101
ccw/src/templates/dashboard-js/components/cli-status.js
Normal file
101
ccw/src/templates/dashboard-js/components/cli-status.js
Normal file
@@ -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 `
|
||||
<div class="cli-tool-card ${isAvailable ? 'available' : 'unavailable'}">
|
||||
<div class="cli-tool-header">
|
||||
<span class="cli-tool-status ${isAvailable ? 'status-available' : 'status-unavailable'}"></span>
|
||||
<span class="cli-tool-name">${tool.charAt(0).toUpperCase() + tool.slice(1)}</span>
|
||||
${isDefault ? '<span class="cli-tool-badge">Default</span>' : ''}
|
||||
</div>
|
||||
<div class="cli-tool-info">
|
||||
${isAvailable
|
||||
? `<span class="text-success">Ready</span>`
|
||||
: `<span class="text-muted-foreground">Not Installed</span>`
|
||||
}
|
||||
</div>
|
||||
${isAvailable && !isDefault
|
||||
? `<button class="btn-sm btn-outline" onclick="setDefaultCliTool('${tool}')">Set Default</button>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="cli-status-header">
|
||||
<h3>CLI Tools</h3>
|
||||
<button class="btn-icon" onclick="loadCliToolStatus()" title="Refresh">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cli-tools-grid">
|
||||
${toolsHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Initialize Lucide icons
|
||||
if (window.lucide) {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Actions ==========
|
||||
function setDefaultCliTool(tool) {
|
||||
defaultCliTool = tool;
|
||||
renderCliStatus();
|
||||
showRefreshToast(`Default CLI tool set to ${tool}`, 'success');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
282
ccw/src/templates/dashboard-js/views/cli-manager.js
Normal file
282
ccw/src/templates/dashboard-js/views/cli-manager.js
Normal file
@@ -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 = `
|
||||
<div class="cli-manager-container">
|
||||
<div class="cli-manager-grid">
|
||||
<!-- Status Panel -->
|
||||
<div class="cli-panel">
|
||||
<div id="cli-status-panel"></div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Execute Panel -->
|
||||
<div class="cli-panel">
|
||||
<div id="cli-execute-panel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Panel -->
|
||||
<div class="cli-panel cli-panel-full">
|
||||
<div id="cli-history-panel"></div>
|
||||
</div>
|
||||
|
||||
<!-- Live Output Panel (shown during execution) -->
|
||||
<div class="cli-panel cli-panel-full ${currentCliExecution ? '' : 'hidden'}" id="cli-output-panel">
|
||||
<div class="cli-output-header">
|
||||
<h3>Execution Output</h3>
|
||||
<div class="cli-output-status">
|
||||
<span id="cli-output-status-indicator" class="status-indicator running"></span>
|
||||
<span id="cli-output-status-text">Running...</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="cli-output-content" id="cli-output-content"></pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<div class="cli-execute-header">
|
||||
<h3>Quick Execute</h3>
|
||||
</div>
|
||||
<div class="cli-execute-form">
|
||||
<div class="cli-execute-row">
|
||||
<div class="cli-form-group">
|
||||
<label for="cli-exec-tool">Tool</label>
|
||||
<select id="cli-exec-tool" class="cli-select">
|
||||
${tools.map(tool => `
|
||||
<option value="${tool}" ${tool === defaultCliTool ? 'selected' : ''}>
|
||||
${tool.charAt(0).toUpperCase() + tool.slice(1)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cli-form-group">
|
||||
<label for="cli-exec-mode">Mode</label>
|
||||
<select id="cli-exec-mode" class="cli-select">
|
||||
${modes.map(mode => `
|
||||
<option value="${mode}" ${mode === 'analysis' ? 'selected' : ''}>
|
||||
${mode.charAt(0).toUpperCase() + mode.slice(1)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-form-group">
|
||||
<label for="cli-exec-prompt">Prompt</label>
|
||||
<textarea id="cli-exec-prompt" class="cli-textarea" placeholder="Enter your prompt..."></textarea>
|
||||
</div>
|
||||
<div class="cli-execute-actions">
|
||||
<button class="btn btn-primary" onclick="executeCliFromDashboard()" ${currentCliExecution ? 'disabled' : ''}>
|
||||
<i data-lucide="play"></i>
|
||||
Execute
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -398,6 +398,21 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- CLI Tools Section -->
|
||||
<div class="mb-2" id="cliToolsNav">
|
||||
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<i data-lucide="terminal" class="nav-section-icon mr-2"></i>
|
||||
<span class="nav-section-title">CLI Tools</span>
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="cli-manager" data-tooltip="CLI Tools Management">
|
||||
<i data-lucide="square-terminal" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1">Manage</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeCliTools">0/3</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar Footer -->
|
||||
|
||||
491
ccw/src/tools/cli-executor.js
Normal file
491
ccw/src/tools/cli-executor.js
Normal file
@@ -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<Object>}
|
||||
*/
|
||||
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<Object>}
|
||||
*/
|
||||
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 };
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user