diff --git a/ccw/src/cli.ts b/ccw/src/cli.ts index c460671d..759154b8 100644 --- a/ccw/src/cli.ts +++ b/ccw/src/cli.ts @@ -209,6 +209,7 @@ export function run(argv: string[]): void { .option('--turn ', 'Turn number for cache (default: latest)') .option('--raw', 'Raw output only (no formatting)') .option('--final', 'Output final result only with usage hint') + .option('--to-file ', 'Save output to file') .action((subcommand, args, options) => cliCommand(subcommand, args, options)); // Memory command diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index 709dcfa1..5ed49fe2 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -132,6 +132,8 @@ interface CliExecOptions { title?: string; // Optional title for review summary // Template/Rules options rule?: string; // Template name for auto-discovery (defines $PROTO and $TMPL env vars) + // Output options + toFile?: string; // Save output to file } /** Cache configuration parsed from --cache */ @@ -580,7 +582,7 @@ async function statusAction(debug?: boolean): Promise { * @param {Object} options - CLI options */ async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise { - const { prompt: optionPrompt, file, tool: userTool, mode = 'analysis', model, cd, includeDirs, stream, resume, id, noNative, cache, injectMode, debug, uncommitted, base, commit, title, rule } = options; + const { prompt: optionPrompt, file, tool: userTool, mode = 'analysis', model, cd, includeDirs, stream, resume, id, noNative, cache, injectMode, debug, uncommitted, base, commit, title, rule, toFile } = options; // Determine the tool to use: explicit --tool option, or defaultTool from config let tool = userTool; @@ -919,6 +921,9 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec mode }); + // Buffer to accumulate output when both --stream and --to-file are specified + let streamBuffer = ''; + // Streaming output handler - broadcasts to dashboard AND writes to stdout const onOutput = (unit: CliOutputUnit) => { // Always broadcast to dashboard for real-time viewing @@ -939,10 +944,14 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec case 'stdout': case 'code': case 'streaming_content': // Show streaming delta content in real-time - process.stdout.write(typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content)); + const content1 = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); + process.stdout.write(content1); + if (toFile) streamBuffer += content1; break; case 'stderr': - process.stderr.write(typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content)); + const content2 = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); + process.stderr.write(content2); + if (toFile) streamBuffer += content2; break; case 'thought': // Optional: display thinking process with different color @@ -955,7 +964,9 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec default: // Other types: output content if available if (unit.content) { - process.stdout.write(typeof unit.content === 'string' ? unit.content : ''); + const content3 = typeof unit.content === 'string' ? unit.content : ''; + process.stdout.write(content3); + if (toFile) streamBuffer += content3; } } } @@ -1006,12 +1017,43 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec const output = result.parsedOutput || result.stdout; if (output) { console.log(output); + + // Save to file if --to-file is specified + if (toFile) { + try { + const { writeFileSync, mkdirSync } = await import('fs'); + const { dirname, resolve } = await import('path'); + const filePath = resolve(cd || process.cwd(), toFile); + const dir = dirname(filePath); + mkdirSync(dir, { recursive: true }); + writeFileSync(filePath, output, 'utf8'); + if (debug) { + console.log(chalk.gray(` Saved output to: ${filePath}`)); + } + } catch (err) { + console.error(chalk.red(` Error saving to file: ${(err as Error).message}`)); + } + } } } // Print summary with execution ID and turn info console.log(); if (result.success) { + // Save streaming output to file if needed + if (stream && toFile && streamBuffer) { + try { + const { writeFileSync, mkdirSync } = await import('fs'); + const { dirname, resolve } = await import('path'); + const filePath = resolve(cd || process.cwd(), toFile); + const dir = dirname(filePath); + mkdirSync(dir, { recursive: true }); + writeFileSync(filePath, streamBuffer, 'utf8'); + } catch (err) { + console.error(chalk.red(` Error saving to file: ${(err as Error).message}`)); + } + } + if (!spinner) { const turnInfo = result.conversation.turn_count > 1 ? ` (turn ${result.conversation.turn_count})` @@ -1033,6 +1075,11 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec if (!stream) { console.log(chalk.dim(` Output (optional): ccw cli output ${result.execution.id}`)); } + if (toFile) { + const { resolve } = await import('path'); + const filePath = resolve(cd || process.cwd(), toFile); + console.log(chalk.green(` Saved to: ${filePath}`)); + } // Notify dashboard: execution completed (legacy) notifyDashboard({ diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index ef29f0d1..6fbac007 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -605,7 +605,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { // API: Execute CLI Tool if (pathname === '/api/cli/execute' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { - const { tool, prompt, mode, format, model, dir, includeDirs, timeout, smartContext, parentExecutionId, category } = body as any; + const { tool, prompt, mode, format, model, dir, includeDirs, timeout, smartContext, parentExecutionId, category, toFile } = body as any; if (!tool || !prompt) { return { error: 'tool and prompt are required', status: 400 }; @@ -696,6 +696,21 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { console.log(`[ActiveExec] Direct execution ${executionId} marked as ${activeExec.status}, retained for ${EXECUTION_RETENTION_MS / 1000}s`); } + // Save output to file if --to-file is specified + if (toFile && result.stdout) { + try { + const { writeFileSync, mkdirSync } = await import('fs'); + const { dirname, resolve } = await import('path'); + const filePath = resolve(dir || initialPath, toFile); + const dirPath = dirname(filePath); + mkdirSync(dirPath, { recursive: true }); + writeFileSync(filePath, result.stdout, 'utf8'); + console.log(`[API] Output saved to: ${filePath}`); + } catch (err) { + console.warn(`[API] Failed to save output to file: ${(err as Error).message}`); + } + } + // Broadcast completion broadcastToClients({ type: 'CLI_EXECUTION_COMPLETED',