feat: Add --to-file option to ccw cli for saving output to files

Adds support for saving CLI execution output directly to files with the following features:
- Support for relative paths: --to-file output.txt
- Support for nested directories: --to-file results/analysis/output.txt (auto-creates directories)
- Support for absolute paths: --to-file /tmp/output.txt or --to-file D:/results/output.txt
- Works in both streaming and non-streaming modes
- Automatically creates parent directories if they don't exist
- Proper error handling with user-friendly messages
- Shows file save location in completion feedback

Implementation details:
- Updated CLI option parser in ccw/src/cli.ts
- Added toFile parameter to CliExecOptions interface
- Implemented file saving logic in execAction() for both streaming and non-streaming modes
- Updated HTTP API endpoint /api/cli/execute to support toFile parameter
- All changes are backward compatible

Testing:
- Tested with relative paths (single and nested directories)
- Tested with absolute paths (Windows and Unix style)
- Tested with streaming mode
- All tests passed successfully
This commit is contained in:
catlog22
2026-01-29 09:48:30 +08:00
parent 4d93ffb06c
commit 11638facf7
3 changed files with 68 additions and 5 deletions

View File

@@ -209,6 +209,7 @@ export function run(argv: string[]): void {
.option('--turn <n>', '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 <path>', 'Save output to file')
.action((subcommand, args, options) => cliCommand(subcommand, args, options));
// Memory command

View File

@@ -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<void> {
* @param {Object} options - CLI options
*/
async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise<void> {
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({

View File

@@ -605,7 +605,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
// 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<boolean> {
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',