diff --git a/.gitignore b/.gitignore index bc7aa370..706af4ad 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ yarn-error.log* # Package files *.tgz - +docs-astro # OS files .DS_Store Thumbs.db diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index f19e7cc6..e8113be3 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -642,7 +642,8 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec console.log(chalk.yellow(' Debug mode enabled\n')); } - // Priority: 1. --file, 2. stdin (piped), 3. --prompt/-p option, 4. positional argument + // Priority: 1. --file, 2. --prompt/-p option, 3. positional argument, 4. stdin (piped) + // IMPORTANT: In host CLIs, stdin may be non-TTY and kept open. Reading fd 0 first can block. // Note: On Windows, quoted arguments like -p "say hello" may be split into // -p "say" and positional "hello". We merge them back together. let finalPrompt: string | undefined; @@ -661,29 +662,26 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec console.error(chalk.red('Error: File is empty')); process.exit(1); } - } else if (!process.stdin.isTTY) { - // Read from stdin (piped input) - enables: echo "prompt" | ccw cli --tool gemini - // This bypasses Windows shell multi-line argument limitations - const { readFileSync } = await import('fs'); - try { - finalPrompt = readFileSync(0, 'utf8').trim(); // fd 0 = stdin - if (debug) { - console.log(chalk.gray(` Read ${finalPrompt.length} chars from stdin`)); - } - } catch { - // stdin not available or empty, fall through to other methods - } - } - - // If no stdin input, try --prompt/-p option or positional argument - if (!finalPrompt) { + } else { if (optionPrompt) { // Use --prompt/-p option (preferred for multi-line) // Merge with positional argument if Windows split the quoted string finalPrompt = positionalPrompt ? `${optionPrompt} ${positionalPrompt}` : optionPrompt; - } else { + } else if (positionalPrompt) { // Fall back to positional argument finalPrompt = positionalPrompt; + } else if (!process.stdin.isTTY) { + // Read from stdin only when no explicit prompt input is provided + // (enables: echo "prompt" | ccw cli --tool gemini) + const { readFileSync } = await import('fs'); + try { + finalPrompt = readFileSync(0, 'utf8').trim(); // fd 0 = stdin + if (debug) { + console.log(chalk.gray(` Read ${finalPrompt.length} chars from stdin`)); + } + } catch { + // stdin not available or empty, keep finalPrompt undefined + } } } @@ -904,14 +902,29 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec const nativeMode = noNative ? ' (prompt-concat)' : ''; const idInfo = id ? ` [${id}]` : ''; + const autoStream = !stream + && !raw + && !finalOnly + && !process.stdout.isTTY + && Boolean(process.env.CLAUDECODE); + const effectiveStream = Boolean(stream || autoStream); + const shouldPassthroughCodexJsonl = effectiveStream + && tool === 'codex' + && !raw + && !finalOnly + && !process.stdout.isTTY + && Boolean(process.env.CLAUDECODE); + // Programmatic output mode: // - `--raw`: stdout/stderr passthrough semantics (minimal noise) // - `--final`: agent-message only semantics (minimal noise) - // - non-TTY stdout (e.g. called from another process): default to final-only unless `--stream` is used - const programmaticOutput = Boolean(raw || finalOnly) || (!process.stdout.isTTY && !stream); + // - non-TTY stdout (e.g. called from another process): default to final-only unless streaming is enabled + const programmaticOutput = shouldPassthroughCodexJsonl + || Boolean(raw || finalOnly) + || (!process.stdout.isTTY && !effectiveStream); const showUi = !programmaticOutput; const useRawOutput = Boolean(raw); - const useFinalOnlyOutput = Boolean(finalOnly) || (!useRawOutput && !process.stdout.isTTY && !stream); + const useFinalOnlyOutput = Boolean(finalOnly) || (!useRawOutput && !process.stdout.isTTY && !effectiveStream); // Show merge details if (isMerge && showUi) { @@ -931,7 +944,7 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec console.log(); } - const spinner = (showUi && !stream) ? createSpinner(` ${spinnerBaseText}`).start() : null; + const spinner = (showUi && !effectiveStream) ? createSpinner(` ${spinnerBaseText}`).start() : null; const elapsedInterval = spinner ? setInterval(() => { const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000); @@ -996,6 +1009,22 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec // Buffer to accumulate output when both --stream and --to-file are specified let streamBuffer = ''; + const shouldSkipCodexPassthroughLine = (rawLine: string): boolean => { + try { + const parsed = JSON.parse(rawLine) as { + type?: string; + item?: { type?: string }; + item_type?: string; + }; + if (!parsed || typeof parsed !== 'object') return false; + const eventType = parsed.type || ''; + const itemType = parsed.item?.type || parsed.item_type || ''; + return eventType.startsWith('item.') && itemType === 'command_execution'; + } catch { + return false; + } + }; + // Streaming output handler - broadcasts to dashboard AND writes to stdout const onOutput = (unit: CliOutputUnit) => { // Always broadcast to dashboard for real-time viewing @@ -1010,8 +1039,17 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec unit // New structured format }); - // Write to terminal only when --stream flag is passed - if (stream) { + // Write to terminal when streaming is enabled (explicit --stream or auto-stream) + if (effectiveStream) { + if (shouldPassthroughCodexJsonl && unit.rawLine) { + if (shouldSkipCodexPassthroughLine(unit.rawLine)) { + return; + } + const line = `${unit.rawLine}\n`; + process.stdout.write(line); + if (toFile) streamBuffer += line; + return; + } switch (unit.type) { case 'stdout': case 'code': @@ -1060,7 +1098,7 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec resume, id: executionId, // unified execution ID (matches broadcast events) noNative, - stream: !!stream, // stream=true → streaming enabled (no cache), stream=false → cache output (default) + stream: effectiveStream, // stream=true → streaming enabled (no cache), stream=false → cache output (default) outputFormat, // Enable JSONL parsing for tools that support it // Codex review options uncommitted, @@ -1086,7 +1124,7 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec // If not streaming (default), print output now // Prefer parsedOutput (from stream parser) over raw stdout for better formatting - if (!stream) { + if (!effectiveStream) { const output = useRawOutput ? result.stdout : (useFinalOnlyOutput ? (result.finalOutput || result.parsedOutput || result.stdout) : (result.parsedOutput || result.stdout)); @@ -1123,7 +1161,7 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec if (result.success) { // Save streaming output to file if needed - if (stream && toFile && streamBuffer) { + if (effectiveStream && toFile && streamBuffer) { try { const { writeFileSync, mkdirSync } = await import('fs'); const { dirname, resolve } = await import('path'); @@ -1156,7 +1194,7 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec console.log(chalk.gray(` Total: ${result.conversation.turn_count} turns, ${(result.conversation.total_duration_ms / 1000).toFixed(1)}s`)); } console.log(chalk.dim(` Continue: ccw cli -p "..." --resume ${result.execution.id}`)); - if (!stream) { + if (!effectiveStream) { console.log(chalk.dim(` Output (optional): ccw cli output ${result.execution.id}`)); } if (toFile) { diff --git a/ccw/src/services/deepwiki-service.ts b/ccw/src/services/deepwiki-service.ts index bcab94d5..ab519377 100644 --- a/ccw/src/services/deepwiki-service.ts +++ b/ccw/src/services/deepwiki-service.ts @@ -250,6 +250,42 @@ export class DeepWikiService { return []; } } + + /** + * Get DeepWiki storage statistics + */ + public getStats(): { files: number; symbols: number; docs: number } { + const db = this.getConnection(); + if (!db) { + return { files: 0, symbols: 0, docs: 0 }; + } + + try { + const filesRow = db.prepare(` + SELECT COUNT(DISTINCT source_file) as count + FROM deepwiki_symbols + `).get() as { count: number } | undefined; + + const symbolsRow = db.prepare(` + SELECT COUNT(*) as count + FROM deepwiki_symbols + `).get() as { count: number } | undefined; + + const docsRow = db.prepare(` + SELECT COUNT(*) as count + FROM deepwiki_docs + `).get() as { count: number } | undefined; + + return { + files: filesRow?.count ?? 0, + symbols: symbolsRow?.count ?? 0, + docs: docsRow?.count ?? 0, + }; + } catch (error) { + console.error('[DeepWiki] Error getting stats:', error); + return { files: 0, symbols: 0, docs: 0 }; + } + } } // Singleton instance diff --git a/ccw/src/tools/cli-output-converter.ts b/ccw/src/tools/cli-output-converter.ts index 2a2bb067..4bd6ea4a 100644 --- a/ccw/src/tools/cli-output-converter.ts +++ b/ccw/src/tools/cli-output-converter.ts @@ -32,6 +32,7 @@ export interface CliOutputUnit { type: CliOutputUnitType; content: T; timestamp: string; // ISO 8601 format + rawLine?: string; // Original JSONL line (for pass-through streaming when needed) } // ========== Parser Interface ========== @@ -234,6 +235,7 @@ export class JsonLinesParser implements IOutputParser { // Map JSON structure to IR type const unit = this.mapJsonToIR(parsed, streamType); if (unit) { + unit.rawLine = trimmed; units.push(unit); } } @@ -250,6 +252,7 @@ export class JsonLinesParser implements IOutputParser { const parsed = JSON.parse(this.buffer.trim()); const unit = this.mapJsonToIR(parsed, 'stdout'); if (unit) { + unit.rawLine = this.buffer.trim(); units.push(unit); } } catch { diff --git a/ccw/tests/cli-command.test.ts b/ccw/tests/cli-command.test.ts index 37044fb0..840f5172 100644 --- a/ccw/tests/cli-command.test.ts +++ b/ccw/tests/cli-command.test.ts @@ -11,6 +11,7 @@ import { after, afterEach, before, describe, it, mock } from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import * as fs from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import inquirer from 'inquirer'; @@ -187,6 +188,179 @@ describe('cli command module', async () => { assert.deepEqual(exitCodes, [0]); }); + it('prefers --prompt over stdin when stdin is non-TTY (avoid blocking)', async () => { + stubHttpRequest(); + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + + const prevStdinIsTty = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const realReadFileSync = fs.readFileSync; + let stdinReadCount = 0; + mock.method(fs, 'readFileSync', ((pathLike: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (pathLike === 0) { + stdinReadCount += 1; + return ''; + } + return (realReadFileSync as unknown as (...all: unknown[]) => unknown)(pathLike, ...args); + }) as unknown as typeof fs.readFileSync); + + const calls: any[] = []; + mock.method(cliExecutorModule.cliExecutorTool, 'execute', async (params: any) => { + calls.push(params); + return { + success: true, + stdout: 'ok', + stderr: '', + execution: { id: 'EXEC-NONTTY', duration_ms: 1, status: 'success' }, + conversation: { turn_count: 1, total_duration_ms: 1 }, + }; + }); + + const exitCodes: Array = []; + mock.method(process as any, 'exit', (code?: number) => { + exitCodes.push(code); + }); + + try { + await cliModule.cliCommand('exec', [], { prompt: 'Hello', tool: 'codex' }); + await new Promise((resolve) => setTimeout(resolve, 150)); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: prevStdinIsTty, configurable: true }); + } + + assert.equal(stdinReadCount, 0); + assert.equal(calls.length, 1); + assert.equal(calls[0].prompt, 'Hello'); + assert.deepEqual(exitCodes, [0]); + }); + + it('auto-enables stream in Claude Code task environment when stdout is non-TTY', async () => { + stubHttpRequest(); + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + + const prevStdoutIsTty = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + + const prevClaudeCode = process.env.CLAUDECODE; + process.env.CLAUDECODE = '1'; + + const calls: any[] = []; + mock.method(cliExecutorModule.cliExecutorTool, 'execute', async (params: any) => { + calls.push(params); + return { + success: true, + stdout: 'ok', + stderr: '', + execution: { id: 'EXEC-AUTO-STREAM', duration_ms: 1, status: 'success' }, + conversation: { turn_count: 1, total_duration_ms: 1 }, + }; + }); + + const exitCodes: Array = []; + mock.method(process as any, 'exit', (code?: number) => { + exitCodes.push(code); + }); + + try { + await cliModule.cliCommand('exec', [], { prompt: 'Hello', tool: 'codex' }); + await new Promise((resolve) => setTimeout(resolve, 150)); + } finally { + if (prevClaudeCode === undefined) { + delete process.env.CLAUDECODE; + } else { + process.env.CLAUDECODE = prevClaudeCode; + } + Object.defineProperty(process.stdout, 'isTTY', { value: prevStdoutIsTty, configurable: true }); + } + + assert.equal(calls.length, 1); + assert.equal(calls[0].stream, true); + assert.deepEqual(exitCodes, [0]); + }); + + it('passes through codex JSONL events in Claude Code task streaming mode', async () => { + stubHttpRequest(); + + const logs: string[] = []; + mock.method(console, 'log', (...args: any[]) => { + logs.push(args.map(String).join(' ')); + }); + mock.method(console, 'error', () => {}); + + const writes: string[] = []; + mock.method(process.stdout as any, 'write', (chunk: any) => { + writes.push(String(chunk)); + return true; + }); + + const prevStdoutIsTty = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + + const prevClaudeCode = process.env.CLAUDECODE; + process.env.CLAUDECODE = '1'; + + mock.method(cliExecutorModule.cliExecutorTool, 'execute', async (_params: any, onOutput?: (unit: any) => void) => { + onOutput?.({ + type: 'metadata', + content: { tool: 'codex', threadId: 'THREAD-1' }, + timestamp: new Date().toISOString(), + rawLine: '{"type":"thread.started","thread_id":"THREAD-1"}', + }); + onOutput?.({ + type: 'progress', + content: { message: 'Turn started', tool: 'codex' }, + timestamp: new Date().toISOString(), + rawLine: '{"type":"turn.started"}', + }); + onOutput?.({ + type: 'progress', + content: { message: 'Executing: Get-ChildItem', tool: 'codex' }, + timestamp: new Date().toISOString(), + rawLine: '{"type":"item.started","item":{"id":"item_cmd_1","type":"command_execution","status":"in_progress"}}', + }); + onOutput?.({ + type: 'code', + content: { command: 'Get-ChildItem', output: '...', status: 'completed' }, + timestamp: new Date().toISOString(), + rawLine: '{"type":"item.completed","item":{"id":"item_cmd_1","type":"command_execution","status":"completed","exit_code":0}}', + }); + return { + success: true, + stdout: '', + stderr: '', + execution: { id: 'EXEC-PASSTHROUGH', duration_ms: 1, status: 'success' }, + conversation: { turn_count: 1, total_duration_ms: 1 }, + }; + }); + + const exitCodes: Array = []; + mock.method(process as any, 'exit', (code?: number) => { + exitCodes.push(code); + }); + + try { + await cliModule.cliCommand('exec', [], { prompt: 'Hello', tool: 'codex' }); + await new Promise((resolve) => setTimeout(resolve, 150)); + } finally { + if (prevClaudeCode === undefined) { + delete process.env.CLAUDECODE; + } else { + process.env.CLAUDECODE = prevClaudeCode; + } + Object.defineProperty(process.stdout, 'isTTY', { value: prevStdoutIsTty, configurable: true }); + } + + const joined = writes.join(''); + assert.ok(joined.includes('{"type":"thread.started","thread_id":"THREAD-1"}\n')); + assert.ok(joined.includes('{"type":"turn.started"}\n')); + assert.equal(joined.includes('"type":"command_execution"'), false); + assert.equal(logs.some((l) => l.includes('Executing codex')), false); + assert.deepEqual(exitCodes, [0]); + }); + it('prints full output hint immediately after stderr truncation (no troubleshooting duplicate)', async () => { stubHttpRequest(); diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs deleted file mode 100644 index e8dfe029..00000000 --- a/scripts/sync-version.mjs +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env node -/** - * 版本同步脚本 - * 用法: - * node scripts/sync-version.mjs # 检查版本状态 - * node scripts/sync-version.mjs --sync # 同步到最新 npm 版本 - * node scripts/sync-version.mjs --tag # 创建对应 git tag - */ - -import { execSync } from 'child_process'; -import { readFileSync, writeFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const rootDir = join(__dirname, '..'); -const packagePath = join(rootDir, 'package.json'); - -function run(cmd, silent = false) { - try { - return execSync(cmd, { cwd: rootDir, encoding: 'utf-8', stdio: silent ? 'pipe' : 'inherit' }); - } catch (e) { - if (!silent) console.error(`Command failed: ${cmd}`); - return null; - } -} - -function runSilent(cmd) { - try { - return execSync(cmd, { cwd: rootDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); - } catch { - return null; - } -} - -function getLocalVersion() { - const pkg = JSON.parse(readFileSync(packagePath, 'utf-8')); - return pkg.version; -} - -function getNpmVersion() { - const result = runSilent('npm view claude-code-workflow version'); - return result; -} - -function getLatestTag() { - const result = runSilent('git describe --tags --abbrev=0 2>/dev/null'); - return result ? result.replace(/^v/, '') : null; -} - -function setLocalVersion(version) { - const pkg = JSON.parse(readFileSync(packagePath, 'utf-8')); - pkg.version = version; - writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n'); - console.log(`✅ Updated package.json to ${version}`); -} - -function createTag(version) { - const tagName = `v${version}`; - run(`git tag -a ${tagName} -m "Release ${tagName}"`); - console.log(`✅ Created tag ${tagName}`); - console.log('💡 Run `git push origin ${tagName}` to push the tag'); -} - -const args = process.argv.slice(2); -const shouldSync = args.includes('--sync'); -const shouldTag = args.includes('--tag'); - -console.log('📦 Version Status\n'); - -const localVersion = getLocalVersion(); -const npmVersion = getNpmVersion(); -const tagVersion = getLatestTag(); - -console.log(` Local (package.json): ${localVersion}`); -console.log(` NPM (latest): ${npmVersion || 'not published'}`); -console.log(` GitHub Tag (latest): ${tagVersion || 'no tags'}`); -console.log(''); - -const allVersions = [localVersion, npmVersion, tagVersion].filter(Boolean); -const allMatch = allVersions.every(v => v === allVersions[0]); - -if (allMatch) { - console.log('✅ All versions are in sync!\n'); -} else { - console.log('⚠️ Versions are out of sync!\n'); - - if (shouldSync && npmVersion) { - console.log(`Syncing to npm version: ${npmVersion}`); - setLocalVersion(npmVersion); - } else if (!shouldSync) { - console.log('💡 Run with --sync to sync local version to npm'); - } -} - -if (shouldTag && localVersion) { - const currentTag = `v${localVersion}`; - const existingTags = runSilent('git tag -l ' + currentTag); - - if (existingTags) { - console.log(`⚠️ Tag ${currentTag} already exists`); - } else { - createTag(localVersion); - } -} - -if (!shouldSync && !shouldTag && !allMatch) { - console.log('Suggested actions:'); - if (npmVersion && localVersion !== npmVersion) { - console.log(` node scripts/sync-version.mjs --sync # Sync to npm ${npmVersion}`); - } - if (tagVersion !== localVersion) { - console.log(` node scripts/sync-version.mjs --tag # Create tag v${localVersion}`); - } -}