mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
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:
@@ -209,6 +209,7 @@ export function run(argv: string[]): void {
|
|||||||
.option('--turn <n>', 'Turn number for cache (default: latest)')
|
.option('--turn <n>', 'Turn number for cache (default: latest)')
|
||||||
.option('--raw', 'Raw output only (no formatting)')
|
.option('--raw', 'Raw output only (no formatting)')
|
||||||
.option('--final', 'Output final result only with usage hint')
|
.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));
|
.action((subcommand, args, options) => cliCommand(subcommand, args, options));
|
||||||
|
|
||||||
// Memory command
|
// Memory command
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ interface CliExecOptions {
|
|||||||
title?: string; // Optional title for review summary
|
title?: string; // Optional title for review summary
|
||||||
// Template/Rules options
|
// Template/Rules options
|
||||||
rule?: string; // Template name for auto-discovery (defines $PROTO and $TMPL env vars)
|
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 */
|
/** Cache configuration parsed from --cache */
|
||||||
@@ -580,7 +582,7 @@ async function statusAction(debug?: boolean): Promise<void> {
|
|||||||
* @param {Object} options - CLI options
|
* @param {Object} options - CLI options
|
||||||
*/
|
*/
|
||||||
async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise<void> {
|
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
|
// Determine the tool to use: explicit --tool option, or defaultTool from config
|
||||||
let tool = userTool;
|
let tool = userTool;
|
||||||
@@ -919,6 +921,9 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
|||||||
mode
|
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
|
// Streaming output handler - broadcasts to dashboard AND writes to stdout
|
||||||
const onOutput = (unit: CliOutputUnit) => {
|
const onOutput = (unit: CliOutputUnit) => {
|
||||||
// Always broadcast to dashboard for real-time viewing
|
// Always broadcast to dashboard for real-time viewing
|
||||||
@@ -939,10 +944,14 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
|||||||
case 'stdout':
|
case 'stdout':
|
||||||
case 'code':
|
case 'code':
|
||||||
case 'streaming_content': // Show streaming delta content in real-time
|
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;
|
break;
|
||||||
case 'stderr':
|
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;
|
break;
|
||||||
case 'thought':
|
case 'thought':
|
||||||
// Optional: display thinking process with different color
|
// Optional: display thinking process with different color
|
||||||
@@ -955,7 +964,9 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
|||||||
default:
|
default:
|
||||||
// Other types: output content if available
|
// Other types: output content if available
|
||||||
if (unit.content) {
|
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;
|
const output = result.parsedOutput || result.stdout;
|
||||||
if (output) {
|
if (output) {
|
||||||
console.log(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
|
// Print summary with execution ID and turn info
|
||||||
console.log();
|
console.log();
|
||||||
if (result.success) {
|
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) {
|
if (!spinner) {
|
||||||
const turnInfo = result.conversation.turn_count > 1
|
const turnInfo = result.conversation.turn_count > 1
|
||||||
? ` (turn ${result.conversation.turn_count})`
|
? ` (turn ${result.conversation.turn_count})`
|
||||||
@@ -1033,6 +1075,11 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
|||||||
if (!stream) {
|
if (!stream) {
|
||||||
console.log(chalk.dim(` Output (optional): ccw cli output ${result.execution.id}`));
|
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)
|
// Notify dashboard: execution completed (legacy)
|
||||||
notifyDashboard({
|
notifyDashboard({
|
||||||
|
|||||||
@@ -605,7 +605,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
// API: Execute CLI Tool
|
// API: Execute CLI Tool
|
||||||
if (pathname === '/api/cli/execute' && req.method === 'POST') {
|
if (pathname === '/api/cli/execute' && req.method === 'POST') {
|
||||||
handlePostRequest(req, res, async (body) => {
|
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) {
|
if (!tool || !prompt) {
|
||||||
return { error: 'tool and prompt are required', status: 400 };
|
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`);
|
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
|
// Broadcast completion
|
||||||
broadcastToClients({
|
broadcastToClients({
|
||||||
type: 'CLI_EXECUTION_COMPLETED',
|
type: 'CLI_EXECUTION_COMPLETED',
|
||||||
|
|||||||
Reference in New Issue
Block a user