claude_stream_viewer 1.0.14 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts ADDED
@@ -0,0 +1,468 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Reads Claude Code stream-json events line-by-line from stdin and pretty-prints
4
+ // the consolidated event layer (system/assistant/user/result). The fine-grained
5
+ // `stream_event` SSE envelopes are ignored — the consolidated events already
6
+ // carry fully assembled content blocks.
7
+ //
8
+ // Usage:
9
+ // claude --output-format stream-json --verbose --permission-mode auto -p "explain quantum computing like I'm 5" | npx claude_stream_viewer
10
+
11
+
12
+ import Readline from 'node:readline';
13
+ import Fs from 'node:fs';
14
+ import * as Commander from 'commander';
15
+ import Chalk from 'chalk';
16
+ import Path from 'node:path';
17
+
18
+ const __dirname = new URL('.', import.meta.url).pathname;
19
+
20
+ const MAX_TOOL_RESULT_LINES = 40;
21
+
22
+ type CliOptions = {
23
+ color: boolean;
24
+ };
25
+
26
+ type ContentBlock = {
27
+ type?: string;
28
+ text?: string;
29
+ thinking?: string;
30
+ id?: string;
31
+ name?: string;
32
+ input?: unknown;
33
+ tool_use_id?: string;
34
+ content?: string | Array<{ type?: string; text?: string }>;
35
+ is_error?: boolean;
36
+ };
37
+
38
+ type ToolInput = Record<string, unknown>;
39
+
40
+ // Partial typing on purpose: only fields we render are listed. Anything we
41
+ // don't recognize falls through to the unknown-event branch.
42
+ type ClaudeEvent = {
43
+ type?: string;
44
+ subtype?: string;
45
+ cwd?: string;
46
+ model?: string;
47
+ tools?: string[];
48
+ session_id?: string;
49
+ claude_code_version?: string;
50
+ message?: {
51
+ content?: ContentBlock[];
52
+ };
53
+ rate_limit_info?: {
54
+ status?: string;
55
+ };
56
+ result?: string;
57
+ total_cost_usd?: number;
58
+ duration_ms?: number;
59
+ num_turns?: number;
60
+ terminal_reason?: string;
61
+ usage?: {
62
+ input_tokens?: number;
63
+ output_tokens?: number;
64
+ cache_read_input_tokens?: number;
65
+ cache_creation_input_tokens?: number;
66
+ };
67
+ };
68
+
69
+ const colors = {
70
+ text: Chalk.white,
71
+ tool: Chalk.cyan,
72
+ system: Chalk.gray,
73
+ error: Chalk.red,
74
+ json: Chalk.dim,
75
+ header: Chalk.yellow.bold,
76
+ thinking: Chalk.gray.italic,
77
+ };
78
+
79
+ ///////////////////////////////////////////////////////////////////////////////
80
+ ///////////////////////////////////////////////////////////////////////////////
81
+ // class MainHelper
82
+ ///////////////////////////////////////////////////////////////////////////////
83
+ ///////////////////////////////////////////////////////////////////////////////
84
+
85
+ class MainHelper {
86
+ private toolNamesById = new Map<string, string>();
87
+
88
+ printHeader(label: string) {
89
+ console.log(colors.header(`\n=== ${label} ===`));
90
+ }
91
+
92
+ printJSON(obj: unknown) {
93
+ console.log(colors.json(JSON.stringify(obj, null, 2)));
94
+ }
95
+
96
+ ///////////////////////////////////////////////////////////////////////////////
97
+ ///////////////////////////////////////////////////////////////////////////////
98
+ // per-event-type renderers
99
+ ///////////////////////////////////////////////////////////////////////////////
100
+ ///////////////////////////////////////////////////////////////////////////////
101
+
102
+ private renderSystem(event: ClaudeEvent) {
103
+ if (event.subtype !== 'init') {
104
+ this.printHeader(`SYSTEM: ${event.subtype ?? 'unknown'}`);
105
+ this.printJSON(event);
106
+ return;
107
+ }
108
+ this.printHeader('SESSION');
109
+ const sessionPrefix = event.session_id !== undefined ? event.session_id.slice(0, 8) : 'unknown';
110
+ const toolsCount = event.tools !== undefined ? event.tools.length : 0;
111
+ console.log(colors.system(`model: ${event.model ?? 'unknown'}`));
112
+ console.log(colors.system(`cwd: ${event.cwd ?? 'unknown'}`));
113
+ console.log(colors.system(`tools: ${toolsCount}`));
114
+ console.log(colors.system(`session: ${sessionPrefix}`));
115
+ if (event.claude_code_version !== undefined) {
116
+ console.log(colors.system(`claude-code: v${event.claude_code_version}`));
117
+ }
118
+ }
119
+
120
+ private renderAssistant(event: ClaudeEvent) {
121
+ const content = event.message?.content;
122
+ if (content === undefined) return;
123
+ for (const block of content) {
124
+ this.renderAssistantBlock(block);
125
+ }
126
+ }
127
+
128
+ private renderAssistantBlock(block: ContentBlock) {
129
+ if (block.type === 'thinking') {
130
+ console.log(colors.system('\n--- thinking ---'));
131
+ console.log(colors.thinking(block.thinking ?? ''));
132
+ return;
133
+ }
134
+ if (block.type === 'text') {
135
+ console.log(colors.text(`\n${block.text ?? ''}`));
136
+ return;
137
+ }
138
+ if (block.type === 'tool_use') {
139
+ this.renderToolUse(block);
140
+ return;
141
+ }
142
+ console.log(colors.system(`\n[assistant block: ${block.type ?? 'unknown'}]`));
143
+ this.printJSON(block);
144
+ }
145
+
146
+ private renderToolUse(block: ContentBlock) {
147
+ const name = block.name ?? 'tool';
148
+ if (block.id !== undefined) {
149
+ this.toolNamesById.set(block.id, name);
150
+ }
151
+ console.log(colors.tool(`\n→ ${name}`));
152
+
153
+ const rawInput = block.input;
154
+ const input: ToolInput = (rawInput !== null && typeof rawInput === 'object')
155
+ ? rawInput as ToolInput
156
+ : {};
157
+ const bodyLines = MainHelper.formatToolBody(name, input);
158
+ for (const line of bodyLines) {
159
+ console.log(` ${line}`);
160
+ }
161
+ }
162
+
163
+ private static formatToolBody(name: string, input: ToolInput): string[] {
164
+ if (name === 'Bash') return MainHelper.formatBashInput(input);
165
+ if (name === 'Read') return MainHelper.formatReadInput(input);
166
+ if (name === 'Write') return MainHelper.formatWriteInput(input);
167
+ if (name === 'Edit') return MainHelper.formatEditInput(input);
168
+ if (name === 'Grep') return MainHelper.formatGrepInput(input);
169
+ if (name === 'Glob') return MainHelper.formatGlobInput(input);
170
+ if (name === 'TodoWrite') return MainHelper.formatTodoWriteInput(input);
171
+ if (name === 'WebFetch') return MainHelper.formatWebFetchInput(input);
172
+ if (name === 'WebSearch') return MainHelper.formatWebSearchInput(input);
173
+ if (name === 'Task' || name === 'Agent') return MainHelper.formatTaskInput(input);
174
+ return MainHelper.formatFallbackInput(input);
175
+ }
176
+
177
+ private static formatBashInput(input: ToolInput): string[] {
178
+ const command = MainHelper.readString(input, 'command') ?? '';
179
+ const description = MainHelper.readString(input, 'description');
180
+ const lines: string[] = [];
181
+ const cmdLines = command.split('\n');
182
+ lines.push(colors.text(`$ ${cmdLines[0] ?? ''}`));
183
+ for (let i = 1; i < cmdLines.length; i++) {
184
+ lines.push(colors.text(cmdLines[i]));
185
+ }
186
+ if (description !== undefined) {
187
+ lines.push(colors.system(`# ${description}`));
188
+ }
189
+ return lines;
190
+ }
191
+
192
+ private static formatReadInput(input: ToolInput): string[] {
193
+ const filePath = MainHelper.readString(input, 'file_path') ?? '';
194
+ const offset = MainHelper.readNumber(input, 'offset');
195
+ const limit = MainHelper.readNumber(input, 'limit');
196
+ let suffix = '';
197
+ if (offset !== undefined && limit !== undefined) {
198
+ suffix = `:${offset}-${offset + limit - 1}`;
199
+ } else if (offset !== undefined) {
200
+ suffix = `:${offset}+`;
201
+ }
202
+ return [colors.text(`${filePath}${suffix}`)];
203
+ }
204
+
205
+ private static formatWriteInput(input: ToolInput): string[] {
206
+ const filePath = MainHelper.readString(input, 'file_path') ?? '';
207
+ return [colors.text(filePath)];
208
+ }
209
+
210
+ private static formatEditInput(input: ToolInput): string[] {
211
+ const filePath = MainHelper.readString(input, 'file_path') ?? '';
212
+ const lines = [colors.text(filePath)];
213
+ if (MainHelper.readBoolean(input, 'replace_all') === true) {
214
+ lines.push(colors.system('replace_all=true'));
215
+ }
216
+ return lines;
217
+ }
218
+
219
+ private static formatGrepInput(input: ToolInput): string[] {
220
+ const pattern = MainHelper.readString(input, 'pattern') ?? '';
221
+ const path = MainHelper.readString(input, 'path');
222
+ const head = path !== undefined ? `"${pattern}" in ${path}` : `"${pattern}"`;
223
+ const flags: string[] = [];
224
+ const outputMode = MainHelper.readString(input, 'output_mode');
225
+ if (outputMode !== undefined) flags.push(`output_mode=${outputMode}`);
226
+ if (MainHelper.readBoolean(input, '-n') === true) flags.push('-n');
227
+ if (MainHelper.readBoolean(input, '-i') === true) flags.push('-i');
228
+ const headLimit = MainHelper.readNumber(input, 'head_limit');
229
+ if (headLimit !== undefined) flags.push(`head_limit=${headLimit}`);
230
+ const glob = MainHelper.readString(input, 'glob');
231
+ if (glob !== undefined) flags.push(`glob=${glob}`);
232
+ const fileType = MainHelper.readString(input, 'type');
233
+ if (fileType !== undefined) flags.push(`type=${fileType}`);
234
+ if (flags.length === 0) {
235
+ return [colors.text(head)];
236
+ }
237
+ return [`${colors.text(head)} ${colors.system(`(${flags.join(', ')})`)}`];
238
+ }
239
+
240
+ private static formatGlobInput(input: ToolInput): string[] {
241
+ const pattern = MainHelper.readString(input, 'pattern') ?? '';
242
+ const path = MainHelper.readString(input, 'path');
243
+ const text = path !== undefined ? `${pattern} in ${path}` : pattern;
244
+ return [colors.text(text)];
245
+ }
246
+
247
+ private static formatTodoWriteInput(input: ToolInput): string[] {
248
+ const todos = input['todos'];
249
+ if (Array.isArray(todos) === false) {
250
+ return [colors.system('(no todos)')];
251
+ }
252
+ return todos.map((todo) => {
253
+ if (typeof todo !== 'object' || todo === null) {
254
+ return colors.text(String(todo));
255
+ }
256
+ const t = todo as Record<string, unknown>;
257
+ const status = typeof t['status'] === 'string' ? t['status'] : '?';
258
+ const content = typeof t['content'] === 'string' ? t['content'] : '';
259
+ let mark = '?';
260
+ if (status === 'pending') mark = ' ';
261
+ else if (status === 'in_progress') mark = '~';
262
+ else if (status === 'completed') mark = 'x';
263
+ return colors.text(`[${mark}] ${content}`);
264
+ });
265
+ }
266
+
267
+ private static formatWebFetchInput(input: ToolInput): string[] {
268
+ const url = MainHelper.readString(input, 'url') ?? '';
269
+ const prompt = MainHelper.readString(input, 'prompt');
270
+ const lines = [colors.text(url)];
271
+ if (prompt !== undefined) {
272
+ lines.push(colors.system(`prompt: ${prompt}`));
273
+ }
274
+ return lines;
275
+ }
276
+
277
+ private static formatWebSearchInput(input: ToolInput): string[] {
278
+ const query = MainHelper.readString(input, 'query') ?? '';
279
+ const flags: string[] = [];
280
+ const allowed = input['allowed_domains'];
281
+ if (Array.isArray(allowed) && allowed.length > 0) {
282
+ flags.push(`allowed_domains=[${allowed.map((d) => String(d)).join(', ')}]`);
283
+ }
284
+ const blocked = input['blocked_domains'];
285
+ if (Array.isArray(blocked) && blocked.length > 0) {
286
+ flags.push(`blocked_domains=[${blocked.map((d) => String(d)).join(', ')}]`);
287
+ }
288
+ const head = colors.text(`"${query}"`);
289
+ if (flags.length === 0) return [head];
290
+ return [`${head} ${colors.system(`(${flags.join(', ')})`)}`];
291
+ }
292
+
293
+ private static formatTaskInput(input: ToolInput): string[] {
294
+ const description = MainHelper.readString(input, 'description') ?? '';
295
+ const subagentType = MainHelper.readString(input, 'subagent_type') ?? 'agent';
296
+ const prompt = MainHelper.readString(input, 'prompt');
297
+ const lines = [colors.text(`[${subagentType}] ${description}`)];
298
+ if (prompt !== undefined) {
299
+ const PROMPT_PREVIEW_LIMIT = 200;
300
+ if (prompt.length <= PROMPT_PREVIEW_LIMIT) {
301
+ lines.push(colors.system(`prompt: ${prompt}`));
302
+ } else {
303
+ const head = prompt.slice(0, PROMPT_PREVIEW_LIMIT);
304
+ lines.push(colors.system(`prompt: ${head}… (${prompt.length} chars)`));
305
+ }
306
+ }
307
+ return lines;
308
+ }
309
+
310
+ private static formatFallbackInput(input: ToolInput): string[] {
311
+ const json = JSON.stringify(input, null, 2);
312
+ return json.split('\n').map((line) => colors.json(line));
313
+ }
314
+
315
+ private static readString(input: ToolInput, key: string): string | undefined {
316
+ const value = input[key];
317
+ return typeof value === 'string' ? value : undefined;
318
+ }
319
+
320
+ private static readNumber(input: ToolInput, key: string): number | undefined {
321
+ const value = input[key];
322
+ return typeof value === 'number' ? value : undefined;
323
+ }
324
+
325
+ private static readBoolean(input: ToolInput, key: string): boolean | undefined {
326
+ const value = input[key];
327
+ return typeof value === 'boolean' ? value : undefined;
328
+ }
329
+
330
+ private renderUser(event: ClaudeEvent) {
331
+ const content = event.message?.content;
332
+ if (content === undefined) return;
333
+ for (const block of content) {
334
+ if (block.type === 'tool_result') {
335
+ this.renderToolResult(block);
336
+ continue;
337
+ }
338
+ console.log(colors.system(`\n[user block: ${block.type ?? 'unknown'}]`));
339
+ this.printJSON(block);
340
+ }
341
+ }
342
+
343
+ private renderToolResult(block: ContentBlock) {
344
+ const toolName = block.tool_use_id !== undefined
345
+ ? this.toolNamesById.get(block.tool_use_id) ?? block.tool_use_id.slice(0, 12)
346
+ : 'unknown';
347
+ const isError = block.is_error === true;
348
+ const label = isError ? `← ${toolName} ERROR` : `← ${toolName} result`;
349
+ console.log(colors.tool(`\n${label}`));
350
+
351
+ const text = MainHelper.toolResultToText(block.content);
352
+ const lines = text.split('\n');
353
+ const colorFn = isError ? colors.error : colors.text;
354
+ if (lines.length <= MAX_TOOL_RESULT_LINES) {
355
+ console.log(colorFn(text));
356
+ return;
357
+ }
358
+ const head = lines.slice(0, MAX_TOOL_RESULT_LINES).join('\n');
359
+ const moreCount = lines.length - MAX_TOOL_RESULT_LINES;
360
+ console.log(colorFn(head));
361
+ console.log(colors.system(`… (truncated, ${moreCount} more lines)`));
362
+ }
363
+
364
+ private static toolResultToText(content: ContentBlock['content']): string {
365
+ if (content === undefined) return '';
366
+ if (typeof content === 'string') return content;
367
+ return content.map((b) => b.text ?? '').join('');
368
+ }
369
+
370
+ private renderRateLimit(event: ClaudeEvent) {
371
+ const status = event.rate_limit_info?.status ?? 'unknown';
372
+ console.log(colors.system(`\n[rate_limit] ${status}`));
373
+ }
374
+
375
+ private renderResult(event: ClaudeEvent) {
376
+ this.printHeader('RESULT');
377
+ if (event.terminal_reason !== undefined) console.log(colors.system(`terminal_reason: ${event.terminal_reason}`));
378
+ if (event.num_turns !== undefined) console.log(colors.system(`turns: ${event.num_turns}`));
379
+ if (event.duration_ms !== undefined) console.log(colors.system(`duration: ${(event.duration_ms / 1000).toFixed(2)}s`));
380
+ if (event.total_cost_usd !== undefined) console.log(colors.system(`cost: $${event.total_cost_usd.toFixed(4)}`));
381
+ const usage = event.usage;
382
+ if (usage !== undefined) {
383
+ console.log(colors.system(
384
+ `tokens: in=${usage.input_tokens ?? 0} out=${usage.output_tokens ?? 0} cache_read=${usage.cache_read_input_tokens ?? 0} cache_create=${usage.cache_creation_input_tokens ?? 0}`,
385
+ ));
386
+ }
387
+ }
388
+
389
+ ///////////////////////////////////////////////////////////////////////////////
390
+ ///////////////////////////////////////////////////////////////////////////////
391
+ // dispatcher
392
+ ///////////////////////////////////////////////////////////////////////////////
393
+ ///////////////////////////////////////////////////////////////////////////////
394
+
395
+ handleEvent(claudeEvent: ClaudeEvent) {
396
+ try {
397
+ const type = claudeEvent.type;
398
+ if (type === 'stream_event') return;
399
+ if (type === 'system') return this.renderSystem(claudeEvent);
400
+ if (type === 'assistant') return this.renderAssistant(claudeEvent);
401
+ if (type === 'user') return this.renderUser(claudeEvent);
402
+ if (type === 'rate_limit_event') return this.renderRateLimit(claudeEvent);
403
+ if (type === 'result') return this.renderResult(claudeEvent);
404
+ this.printHeader(`${type ?? 'unknown'}`);
405
+ this.printJSON(claudeEvent);
406
+ } catch (err) {
407
+ console.error(colors.error('Error processing event:'), err);
408
+ this.printJSON(claudeEvent);
409
+ }
410
+ }
411
+ }
412
+
413
+ ///////////////////////////////////////////////////////////////////////////////
414
+ ///////////////////////////////////////////////////////////////////////////////
415
+ // function main
416
+ ///////////////////////////////////////////////////////////////////////////////
417
+ ///////////////////////////////////////////////////////////////////////////////
418
+
419
+ async function main(): Promise<void> {
420
+
421
+ const packageJsonPath = Path.join(__dirname, '..', 'package.json');
422
+ const packageJson: object = JSON.parse(await Fs.promises.readFile(packageJsonPath, 'utf-8'));
423
+ const packageVersion = (packageJson as { version?: string }).version ?? 'unknown';
424
+
425
+ const program = new Commander.Command();
426
+ program
427
+ .name('claude_stream_viewer')
428
+ .description('Pretty-prints Claude Code consolidated stream-json events from stdin with colorized output.')
429
+ .version(packageVersion)
430
+ .option('--no-color', 'disable colored output')
431
+ .parse(process.argv);
432
+
433
+ const options = program.opts<CliOptions>();
434
+ if (options.color === false) {
435
+ Chalk.level = 0;
436
+ }
437
+
438
+ const helper = new MainHelper();
439
+ const readline = Readline.createInterface({
440
+ input: process.stdin,
441
+ crlfDelay: Infinity,
442
+ });
443
+
444
+ readline.on('line', (line) => {
445
+ if (line.trim() === '') return;
446
+ try {
447
+ const parsed = JSON.parse(line);
448
+ helper.handleEvent(parsed);
449
+ } catch (err) {
450
+ console.error(colors.error('Invalid JSON:'), line);
451
+ }
452
+ });
453
+
454
+ await new Promise<void>((resolve) => {
455
+ readline.on('close', () => {
456
+ console.log(colors.system('\n[stream ended]'));
457
+ resolve();
458
+ });
459
+ });
460
+ }
461
+
462
+ ///////////////////////////////////////////////////////////////////////////////
463
+ ///////////////////////////////////////////////////////////////////////////////
464
+ //
465
+ ///////////////////////////////////////////////////////////////////////////////
466
+ ///////////////////////////////////////////////////////////////////////////////
467
+
468
+ await main();