@towles/tool 0.0.86 → 0.0.88

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.86",
3
+ "version": "0.0.88",
4
4
  "description": "One off quality of life scripts that I use on a daily basis.",
5
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
6
6
  "bugs": {
@@ -7,6 +7,7 @@ import { getConfig } from "./config.js";
7
7
  import { sleep } from "./shell.js";
8
8
  import { spawnClaude as defaultSpawnClaude } from "./spawn-claude.js";
9
9
  import type { SpawnClaudeFn } from "./spawn-claude.js";
10
+ import { parseStreamLine } from "./stream-parser.js";
10
11
 
11
12
  // ── Claude CLI ──
12
13
 
@@ -74,6 +75,31 @@ export async function runClaude(opts: {
74
75
  throw lastError ?? new Error("runClaude failed after all retries");
75
76
  }
76
77
 
78
+ function logActivityEvent(event: ReturnType<typeof parseStreamLine>, log: ClaudeLogger): void {
79
+ if (!event) return;
80
+
81
+ switch (event.kind) {
82
+ case "tool_use":
83
+ log.info(
84
+ ` ${pc.dim("\u21B3")} ${event.name}${event.detail ? pc.dim(` ${event.detail}`) : ""}`,
85
+ );
86
+ break;
87
+ case "thinking":
88
+ log.info(
89
+ ` ${pc.dim("\u21B3")} ${pc.italic("thinking")}${event.summary ? pc.dim(` ${event.summary}`) : ""}`,
90
+ );
91
+ break;
92
+ case "text":
93
+ if (event.content.trim()) {
94
+ log.info(` ${pc.dim("\u21B3")} ${pc.dim(event.content.split("\n")[0].trim())}`);
95
+ }
96
+ break;
97
+ case "result":
98
+ // Handled separately via capturedResult
99
+ break;
100
+ }
101
+ }
102
+
77
103
  function runClaudeStreaming(
78
104
  args: string[],
79
105
  spawnFn: SpawnClaudeFn,
@@ -93,19 +119,28 @@ function runClaudeStreaming(
93
119
 
94
120
  rl.on("line", (line) => {
95
121
  if (!line.trim()) return;
122
+
123
+ const activity = parseStreamLine(line);
124
+ logActivityEvent(activity, log);
125
+
126
+ if (activity?.kind === "result") {
127
+ capturedResult = {
128
+ result: "",
129
+ is_error: activity.isError,
130
+ total_cost_usd: activity.costUsd,
131
+ num_turns: activity.numTurns,
132
+ };
133
+ }
134
+
135
+ // Track turn count from intermediate events (parser returns null for these)
96
136
  try {
97
- const event = JSON.parse(line) as Record<string, unknown>;
98
- handleStreamEvent(event, log, (turns) => {
99
- turnCount = turns;
100
- });
101
-
102
- if ("result" in event && "is_error" in event && "num_turns" in event) {
103
- capturedResult = {
104
- result: String(event.result ?? ""),
105
- is_error: Boolean(event.is_error),
106
- total_cost_usd: Number(event.total_cost_usd ?? 0),
107
- num_turns: Number(event.num_turns),
108
- };
137
+ const raw = JSON.parse(line) as Record<string, unknown>;
138
+ if (typeof raw.num_turns === "number" && !("result" in raw)) {
139
+ turnCount = raw.num_turns as number;
140
+ }
141
+ // Capture the result text from the final event
142
+ if ("result" in raw && capturedResult) {
143
+ capturedResult.result = String(raw.result ?? "");
109
144
  }
110
145
  } catch {
111
146
  // Skip non-JSON lines
@@ -129,74 +164,3 @@ function runClaudeStreaming(
129
164
  });
130
165
  });
131
166
  }
132
-
133
- function truncate(s: string, max: number): string {
134
- return s.length > max ? s.slice(0, max) + "\u2026" : s;
135
- }
136
-
137
- function toolDetail(block: Record<string, unknown>): string {
138
- const input =
139
- typeof block.input === "object" && block.input !== null
140
- ? (block.input as Record<string, unknown>)
141
- : null;
142
- if (!input) return "";
143
-
144
- const filePath = input.file_path ?? input.path;
145
- if (typeof filePath === "string") {
146
- let detail = pc.dim(` ${filePath}`);
147
- // Show edit context for Edit tool
148
- if (typeof input.old_string === "string" && typeof input.new_string === "string") {
149
- const old = truncate(input.old_string.split("\n")[0].trim(), 40);
150
- const replacement = truncate(input.new_string.split("\n")[0].trim(), 40);
151
- detail += pc.dim(` "${old}" → "${replacement}"`);
152
- }
153
- return detail;
154
- }
155
- if (typeof input.pattern === "string") return pc.dim(` ${input.pattern}`);
156
- if (typeof input.command === "string") {
157
- return pc.dim(` ${truncate(input.command, 60)}`);
158
- }
159
- // TodoWrite/TaskCreate — show subject
160
- if (typeof input.subject === "string") return pc.dim(` ${truncate(input.subject, 60)}`);
161
- return "";
162
- }
163
-
164
- function logToolUse(block: Record<string, unknown>, log: ClaudeLogger): void {
165
- const name = block.name;
166
- if (typeof name === "string") {
167
- log.info(` ${pc.dim("\u21B3")} ${name}${toolDetail(block)}`);
168
- }
169
- }
170
-
171
- function handleStreamEvent(
172
- event: Record<string, unknown>,
173
- log: ClaudeLogger,
174
- onTurn: (count: number) => void,
175
- ): void {
176
- // Only handle stream_event — assistant turn events duplicate the same tools
177
- if (event.type === "stream_event" && typeof event.event === "object" && event.event !== null) {
178
- const inner = event.event as Record<string, unknown>;
179
-
180
- if (
181
- inner.type === "content_block_start" &&
182
- typeof inner.content_block === "object" &&
183
- inner.content_block !== null
184
- ) {
185
- const block = inner.content_block as Record<string, unknown>;
186
- if (block.type === "tool_use") {
187
- logToolUse(block, log);
188
- } else if (block.type === "thinking") {
189
- const thinkingText =
190
- typeof block.thinking === "string" && block.thinking.length > 0
191
- ? pc.dim(` ${truncate(block.thinking.split("\n")[0].trim(), 60)}`)
192
- : "";
193
- log.info(` ${pc.dim("\u21B3")} ${pc.italic("thinking")}${thinkingText}`);
194
- }
195
- }
196
- }
197
-
198
- // Turn count tracking
199
- if (typeof event.num_turns === "number" && !("result" in event)) {
200
- onTurn(event.num_turns as number);
201
- }
202
- }
@@ -0,0 +1,412 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseStreamLine } from "./stream-parser";
3
+
4
+ describe("parseStreamLine", () => {
5
+ it("returns null for empty lines", () => {
6
+ expect(parseStreamLine("")).toBeNull();
7
+ expect(parseStreamLine(" ")).toBeNull();
8
+ });
9
+
10
+ it("returns null for invalid JSON", () => {
11
+ expect(parseStreamLine("not json")).toBeNull();
12
+ expect(parseStreamLine("{broken")).toBeNull();
13
+ });
14
+
15
+ it("returns null for unrecognized event types", () => {
16
+ expect(parseStreamLine(JSON.stringify({ type: "ping" }))).toBeNull();
17
+ expect(parseStreamLine(JSON.stringify({ type: "system", data: {} }))).toBeNull();
18
+ });
19
+
20
+ describe("result events", () => {
21
+ it("parses a result event", () => {
22
+ const line = JSON.stringify({
23
+ result: "Task completed",
24
+ is_error: false,
25
+ num_turns: 5,
26
+ cost_usd: 0.0342,
27
+ duration_ms: 12000,
28
+ });
29
+
30
+ expect(parseStreamLine(line)).toEqual({
31
+ kind: "result",
32
+ costUsd: 0.0342,
33
+ durationMs: 12000,
34
+ numTurns: 5,
35
+ isError: false,
36
+ });
37
+ });
38
+
39
+ it("parses an error result", () => {
40
+ const line = JSON.stringify({
41
+ result: "Failed",
42
+ is_error: true,
43
+ num_turns: 1,
44
+ cost_usd: 0.001,
45
+ duration_ms: 500,
46
+ });
47
+
48
+ expect(parseStreamLine(line)).toEqual({
49
+ kind: "result",
50
+ costUsd: 0.001,
51
+ durationMs: 500,
52
+ numTurns: 1,
53
+ isError: true,
54
+ });
55
+ });
56
+
57
+ it("defaults cost and duration to 0 when missing", () => {
58
+ const line = JSON.stringify({
59
+ result: "done",
60
+ is_error: false,
61
+ num_turns: 3,
62
+ });
63
+
64
+ const event = parseStreamLine(line);
65
+ expect(event?.kind).toBe("result");
66
+ if (event?.kind === "result") {
67
+ expect(event.costUsd).toBe(0);
68
+ expect(event.durationMs).toBe(0);
69
+ }
70
+ });
71
+
72
+ it("handles total_cost_usd field name", () => {
73
+ const line = JSON.stringify({
74
+ result: "done",
75
+ is_error: false,
76
+ num_turns: 2,
77
+ total_cost_usd: 0.05,
78
+ });
79
+
80
+ const event = parseStreamLine(line);
81
+ if (event?.kind === "result") {
82
+ expect(event.costUsd).toBe(0.05);
83
+ }
84
+ });
85
+ });
86
+
87
+ describe("stream_event tool_use", () => {
88
+ it("parses a tool_use event with file_path input", () => {
89
+ const line = JSON.stringify({
90
+ type: "stream_event",
91
+ event: {
92
+ type: "content_block_start",
93
+ content_block: {
94
+ type: "tool_use",
95
+ name: "Read",
96
+ input: { file_path: "/home/user/code/main.ts" },
97
+ },
98
+ },
99
+ });
100
+
101
+ expect(parseStreamLine(line)).toEqual({
102
+ kind: "tool_use",
103
+ name: "Read",
104
+ detail: "/home/user/code/main.ts",
105
+ input: { file_path: "/home/user/code/main.ts" },
106
+ });
107
+ });
108
+
109
+ it("parses Edit tool with old/new string detail", () => {
110
+ const line = JSON.stringify({
111
+ type: "stream_event",
112
+ event: {
113
+ type: "content_block_start",
114
+ content_block: {
115
+ type: "tool_use",
116
+ name: "Edit",
117
+ input: {
118
+ file_path: "src/index.ts",
119
+ old_string: "const x = 1;",
120
+ new_string: "const x = 2;",
121
+ },
122
+ },
123
+ },
124
+ });
125
+
126
+ const event = parseStreamLine(line);
127
+ expect(event?.kind).toBe("tool_use");
128
+ if (event?.kind === "tool_use") {
129
+ expect(event.name).toBe("Edit");
130
+ expect(event.detail).toContain("src/index.ts");
131
+ expect(event.detail).toContain('"const x = 1;"');
132
+ expect(event.detail).toContain('"const x = 2;"');
133
+ }
134
+ });
135
+
136
+ it("parses Bash tool with command detail", () => {
137
+ const line = JSON.stringify({
138
+ type: "stream_event",
139
+ event: {
140
+ type: "content_block_start",
141
+ content_block: {
142
+ type: "tool_use",
143
+ name: "Bash",
144
+ input: { command: "pnpm test" },
145
+ },
146
+ },
147
+ });
148
+
149
+ const event = parseStreamLine(line);
150
+ if (event?.kind === "tool_use") {
151
+ expect(event.name).toBe("Bash");
152
+ expect(event.detail).toBe("pnpm test");
153
+ }
154
+ });
155
+
156
+ it("parses Grep tool with pattern detail", () => {
157
+ const line = JSON.stringify({
158
+ type: "stream_event",
159
+ event: {
160
+ type: "content_block_start",
161
+ content_block: {
162
+ type: "tool_use",
163
+ name: "Grep",
164
+ input: { pattern: "parseStreamLine" },
165
+ },
166
+ },
167
+ });
168
+
169
+ const event = parseStreamLine(line);
170
+ if (event?.kind === "tool_use") {
171
+ expect(event.name).toBe("Grep");
172
+ expect(event.detail).toBe("parseStreamLine");
173
+ }
174
+ });
175
+
176
+ it("returns empty detail for tool with no recognized input fields", () => {
177
+ const line = JSON.stringify({
178
+ type: "stream_event",
179
+ event: {
180
+ type: "content_block_start",
181
+ content_block: {
182
+ type: "tool_use",
183
+ name: "CustomTool",
184
+ input: { some_field: "value" },
185
+ },
186
+ },
187
+ });
188
+
189
+ const event = parseStreamLine(line);
190
+ if (event?.kind === "tool_use") {
191
+ expect(event.detail).toBe("");
192
+ }
193
+ });
194
+
195
+ it("handles tool_use with no input", () => {
196
+ const line = JSON.stringify({
197
+ type: "stream_event",
198
+ event: {
199
+ type: "content_block_start",
200
+ content_block: { type: "tool_use", name: "SomeTool" },
201
+ },
202
+ });
203
+
204
+ const event = parseStreamLine(line);
205
+ if (event?.kind === "tool_use") {
206
+ expect(event.input).toEqual({});
207
+ expect(event.detail).toBe("");
208
+ }
209
+ });
210
+ });
211
+
212
+ describe("stream_event thinking", () => {
213
+ it("parses a thinking event", () => {
214
+ const line = JSON.stringify({
215
+ type: "stream_event",
216
+ event: {
217
+ type: "content_block_start",
218
+ content_block: {
219
+ type: "thinking",
220
+ thinking: "I need to read the file first.",
221
+ },
222
+ },
223
+ });
224
+
225
+ expect(parseStreamLine(line)).toEqual({
226
+ kind: "thinking",
227
+ summary: "I need to read the file first.",
228
+ });
229
+ });
230
+
231
+ it("truncates long thinking to 120 chars", () => {
232
+ const longThinking = "A".repeat(200);
233
+ const line = JSON.stringify({
234
+ type: "stream_event",
235
+ event: {
236
+ type: "content_block_start",
237
+ content_block: { type: "thinking", thinking: longThinking },
238
+ },
239
+ });
240
+
241
+ const event = parseStreamLine(line);
242
+ if (event?.kind === "thinking") {
243
+ expect(event.summary.length).toBeLessThanOrEqual(121);
244
+ expect(event.summary).toContain("\u2026");
245
+ }
246
+ });
247
+
248
+ it("uses only first line of multi-line thinking", () => {
249
+ const line = JSON.stringify({
250
+ type: "stream_event",
251
+ event: {
252
+ type: "content_block_start",
253
+ content_block: {
254
+ type: "thinking",
255
+ thinking: "First line\nSecond line\nThird line",
256
+ },
257
+ },
258
+ });
259
+
260
+ const event = parseStreamLine(line);
261
+ if (event?.kind === "thinking") {
262
+ expect(event.summary).toBe("First line");
263
+ }
264
+ });
265
+
266
+ it("handles empty thinking", () => {
267
+ const line = JSON.stringify({
268
+ type: "stream_event",
269
+ event: {
270
+ type: "content_block_start",
271
+ content_block: { type: "thinking" },
272
+ },
273
+ });
274
+
275
+ const event = parseStreamLine(line);
276
+ if (event?.kind === "thinking") {
277
+ expect(event.summary).toBe("");
278
+ }
279
+ });
280
+ });
281
+
282
+ describe("stream_event text", () => {
283
+ it("parses a text event", () => {
284
+ const line = JSON.stringify({
285
+ type: "stream_event",
286
+ event: {
287
+ type: "content_block_start",
288
+ content_block: {
289
+ type: "text",
290
+ text: "Here is the implementation plan:",
291
+ },
292
+ },
293
+ });
294
+
295
+ expect(parseStreamLine(line)).toEqual({
296
+ kind: "text",
297
+ content: "Here is the implementation plan:",
298
+ });
299
+ });
300
+ });
301
+
302
+ describe("assistant message format", () => {
303
+ it("parses tool_use from assistant message content array", () => {
304
+ const line = JSON.stringify({
305
+ type: "assistant",
306
+ message: {
307
+ content: [
308
+ {
309
+ type: "tool_use",
310
+ name: "Write",
311
+ input: { file_path: "/tmp/test.ts", content: "hello" },
312
+ },
313
+ ],
314
+ },
315
+ });
316
+
317
+ const event = parseStreamLine(line);
318
+ expect(event?.kind).toBe("tool_use");
319
+ if (event?.kind === "tool_use") {
320
+ expect(event.name).toBe("Write");
321
+ expect(event.detail).toBe("/tmp/test.ts");
322
+ }
323
+ });
324
+
325
+ it("parses thinking from assistant message content array", () => {
326
+ const line = JSON.stringify({
327
+ type: "assistant",
328
+ message: {
329
+ content: [{ type: "thinking", thinking: "Let me think about this" }],
330
+ },
331
+ });
332
+
333
+ expect(parseStreamLine(line)).toEqual({
334
+ kind: "thinking",
335
+ summary: "Let me think about this",
336
+ });
337
+ });
338
+
339
+ it("parses text from assistant message content array", () => {
340
+ const line = JSON.stringify({
341
+ type: "assistant",
342
+ message: {
343
+ content: [{ type: "text", text: "I will now implement the feature." }],
344
+ },
345
+ });
346
+
347
+ expect(parseStreamLine(line)).toEqual({
348
+ kind: "text",
349
+ content: "I will now implement the feature.",
350
+ });
351
+ });
352
+
353
+ it("returns null for empty content array", () => {
354
+ const line = JSON.stringify({
355
+ type: "assistant",
356
+ message: { content: [] },
357
+ });
358
+
359
+ expect(parseStreamLine(line)).toBeNull();
360
+ });
361
+
362
+ it("skips unrecognized blocks and returns first recognized one", () => {
363
+ const line = JSON.stringify({
364
+ type: "assistant",
365
+ message: {
366
+ content: [
367
+ { type: "unknown_block", data: "ignored" },
368
+ { type: "tool_use", name: "Read", input: { file_path: "src/main.ts" } },
369
+ ],
370
+ },
371
+ });
372
+
373
+ const event = parseStreamLine(line);
374
+ expect(event?.kind).toBe("tool_use");
375
+ if (event?.kind === "tool_use") {
376
+ expect(event.name).toBe("Read");
377
+ }
378
+ });
379
+ });
380
+
381
+ describe("ignores non-interesting events", () => {
382
+ it("ignores content_block_delta", () => {
383
+ const line = JSON.stringify({
384
+ type: "stream_event",
385
+ event: {
386
+ type: "content_block_delta",
387
+ delta: { type: "text_delta", text: "partial" },
388
+ },
389
+ });
390
+
391
+ expect(parseStreamLine(line)).toBeNull();
392
+ });
393
+
394
+ it("ignores message_start", () => {
395
+ const line = JSON.stringify({
396
+ type: "stream_event",
397
+ event: { type: "message_start", message: {} },
398
+ });
399
+
400
+ expect(parseStreamLine(line)).toBeNull();
401
+ });
402
+
403
+ it("ignores message_stop", () => {
404
+ const line = JSON.stringify({
405
+ type: "stream_event",
406
+ event: { type: "message_stop" },
407
+ });
408
+
409
+ expect(parseStreamLine(line)).toBeNull();
410
+ });
411
+ });
412
+ });
@@ -0,0 +1,139 @@
1
+ export interface AgentToolEvent {
2
+ kind: "tool_use";
3
+ name: string;
4
+ detail: string;
5
+ input: Record<string, unknown>;
6
+ }
7
+
8
+ export interface AgentThinkingEvent {
9
+ kind: "thinking";
10
+ summary: string;
11
+ }
12
+
13
+ export interface AgentTextEvent {
14
+ kind: "text";
15
+ content: string;
16
+ }
17
+
18
+ export interface AgentResultEvent {
19
+ kind: "result";
20
+ costUsd: number;
21
+ durationMs: number;
22
+ numTurns: number;
23
+ isError: boolean;
24
+ }
25
+
26
+ export type AgentActivityEvent =
27
+ | AgentToolEvent
28
+ | AgentThinkingEvent
29
+ | AgentTextEvent
30
+ | AgentResultEvent;
31
+
32
+ function truncate(s: string, max: number): string {
33
+ return s.length > max ? s.slice(0, max) + "\u2026" : s;
34
+ }
35
+
36
+ function toolDetail(block: Record<string, unknown>): string {
37
+ const input =
38
+ typeof block.input === "object" && block.input !== null
39
+ ? (block.input as Record<string, unknown>)
40
+ : null;
41
+ if (!input) return "";
42
+
43
+ const filePath = input.file_path ?? input.path;
44
+ if (typeof filePath === "string") {
45
+ let detail = filePath;
46
+ if (typeof input.old_string === "string" && typeof input.new_string === "string") {
47
+ const old = truncate(input.old_string.split("\n")[0].trim(), 40);
48
+ const replacement = truncate(input.new_string.split("\n")[0].trim(), 40);
49
+ detail += ` "${old}" -> "${replacement}"`;
50
+ }
51
+ return detail;
52
+ }
53
+ if (typeof input.pattern === "string") return input.pattern;
54
+ if (typeof input.command === "string") return truncate(input.command, 60);
55
+ if (typeof input.subject === "string") return truncate(input.subject, 60);
56
+ return "";
57
+ }
58
+
59
+ function parseContentBlock(block: Record<string, unknown>): AgentActivityEvent | null {
60
+ if (block.type === "tool_use" && typeof block.name === "string") {
61
+ return {
62
+ kind: "tool_use",
63
+ name: block.name,
64
+ detail: toolDetail(block),
65
+ input:
66
+ typeof block.input === "object" && block.input !== null
67
+ ? (block.input as Record<string, unknown>)
68
+ : {},
69
+ };
70
+ }
71
+
72
+ if (block.type === "thinking") {
73
+ const text = typeof block.thinking === "string" ? block.thinking : "";
74
+ return {
75
+ kind: "thinking",
76
+ summary: truncate(text.split("\n")[0].trim(), 120),
77
+ };
78
+ }
79
+
80
+ if (block.type === "text" && typeof block.text === "string") {
81
+ return {
82
+ kind: "text",
83
+ content: block.text,
84
+ };
85
+ }
86
+
87
+ return null;
88
+ }
89
+
90
+ /** Parse a single NDJSON line from Claude Code's stream-json output */
91
+ export function parseStreamLine(line: string): AgentActivityEvent | null {
92
+ if (!line.trim()) return null;
93
+
94
+ let event: Record<string, unknown>;
95
+ try {
96
+ event = JSON.parse(line) as Record<string, unknown>;
97
+ } catch {
98
+ return null;
99
+ }
100
+
101
+ // Result event — top-level with result/is_error/num_turns
102
+ if ("result" in event && "is_error" in event && "num_turns" in event) {
103
+ return {
104
+ kind: "result",
105
+ costUsd: Number(event.cost_usd ?? event.total_cost_usd ?? 0),
106
+ durationMs: Number(event.duration_ms ?? 0),
107
+ numTurns: Number(event.num_turns),
108
+ isError: Boolean(event.is_error),
109
+ };
110
+ }
111
+
112
+ // Stream events — tool_use, thinking, text inside content_block_start
113
+ if (event.type === "stream_event" && typeof event.event === "object" && event.event !== null) {
114
+ const inner = event.event as Record<string, unknown>;
115
+
116
+ if (
117
+ inner.type === "content_block_start" &&
118
+ typeof inner.content_block === "object" &&
119
+ inner.content_block !== null
120
+ ) {
121
+ return parseContentBlock(inner.content_block as Record<string, unknown>);
122
+ }
123
+ }
124
+
125
+ // Assistant message events — content array with tool_use/thinking/text
126
+ if (event.type === "assistant" && typeof event.message === "object" && event.message !== null) {
127
+ const message = event.message as Record<string, unknown>;
128
+ if (Array.isArray(message.content)) {
129
+ for (const block of message.content) {
130
+ if (typeof block === "object" && block !== null) {
131
+ const parsed = parseContentBlock(block as Record<string, unknown>);
132
+ if (parsed) return parsed;
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ return null;
139
+ }