@towles/tool 0.0.60 → 0.0.61

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.60",
3
+ "version": "0.0.61",
4
4
  "description": "CLI tool with auto-claude pipeline, developer tools, and journaling via markdown.",
5
5
  "keywords": [
6
6
  "auto-claude",
@@ -202,7 +202,11 @@ async function syncWithRemote(): Promise<void> {
202
202
  }
203
203
  const status = await git(["status", "--porcelain"]);
204
204
  if (status.length > 0) {
205
- throw new Error("Working tree has uncommitted changes. Commit or stash them first.");
205
+ const files = status.trim().split("\n");
206
+ consola.warn(`Working tree has ${files.length} uncommitted change(s):`);
207
+ for (const file of files) {
208
+ consola.warn(` ${file.trim()}`);
209
+ }
206
210
  }
207
211
  await git(["pull", cfg.remote, cfg.mainBranch]);
208
212
  }
@@ -5,10 +5,11 @@ import { colors } from "consola/utils";
5
5
  import { Fzf } from "fzf";
6
6
  import consola from "consola";
7
7
 
8
+ import { exec } from "tinyexec";
9
+
8
10
  import { BaseCommand } from "../base.js";
9
11
  import type { Issue } from "../../utils/git/gh-cli-wrapper.js";
10
12
  import { getIssues, isGithubCliInstalled } from "../../utils/git/gh-cli-wrapper.js";
11
- import { createBranch } from "../../utils/git/git-wrapper.js";
12
13
  import { createBranchNameFromIssue } from "../../utils/git/branch-name.js";
13
14
  import { getTerminalColumns, limitText, printWithHexColor } from "../../utils/render.js";
14
15
 
@@ -130,7 +131,7 @@ export default class GhBranch extends BaseCommand {
130
131
  );
131
132
 
132
133
  const branchName = createBranchNameFromIssue(selectedIssue);
133
- createBranch({ branchName });
134
+ await exec("git", ["checkout", "-b", branchName]);
134
135
  } catch {
135
136
  this.exit(1);
136
137
  }
@@ -9,19 +9,21 @@ import { initConfig } from "./config";
9
9
  import { ARTIFACTS } from "./prompt-templates/index";
10
10
  import {
11
11
  buildTestContext,
12
+ createSpawnClaudeMock,
12
13
  createTestRepoWithRemote,
13
14
  errorClaudeJson,
14
15
  successClaudeJson,
15
16
  } from "./test-helpers";
16
- import type { TestRepo } from "./test-helpers";
17
+ import type { MockClaudeImpl, TestRepo } from "./test-helpers";
17
18
  import type { IssueContext } from "./utils";
18
19
 
19
20
  consola.level = -999;
20
21
 
21
- // ── Mock tinyexec: intercept "claude" and "gh" calls, pass through git ──
22
+ let mockClaudeImpl: MockClaudeImpl = null;
23
+ vi.mock("./spawn-claude", () => createSpawnClaudeMock(() => mockClaudeImpl));
24
+
25
+ // ── Mock tinyexec: intercept "gh" calls, pass through git ──
22
26
 
23
- let mockClaudeImpl: ((args: string[]) => Promise<{ stdout: string; exitCode: number }>) | null =
24
- null;
25
27
  let mockGhImpl: ((args: string[]) => Promise<{ stdout: string; exitCode: number }>) | null = null;
26
28
 
27
29
  vi.mock("tinyexec", async (importOriginal) => {
@@ -34,12 +36,6 @@ vi.mock("tinyexec", async (importOriginal) => {
34
36
  args: string[],
35
37
  opts?: Record<string, unknown>,
36
38
  ): Promise<{ stdout: string; exitCode: number }> => {
37
- if (cmd === "claude" && mockClaudeImpl) {
38
- return mockClaudeImpl(args);
39
- }
40
- if (cmd === "claude") {
41
- throw new Error("Unexpected claude call -- set mockClaudeImpl");
42
- }
43
39
  if (cmd === "gh" && mockGhImpl) {
44
40
  return mockGhImpl(args);
45
41
  }
@@ -82,7 +78,7 @@ describe("runPipeline", () => {
82
78
  it("writes initial-ramblings.md on first run", async () => {
83
79
  const { runPipeline } = await import("./pipeline");
84
80
 
85
- mockClaudeImpl = async () => ({ stdout: errorClaudeJson(), exitCode: 0 });
81
+ mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
86
82
 
87
83
  await runPipeline(ctx);
88
84
 
@@ -101,7 +97,7 @@ describe("runPipeline", () => {
101
97
  const ramblingsPath = join(ctx.issueDir, ARTIFACTS.initialRamblings);
102
98
  writeFileSync(ramblingsPath, "# Existing ramblings");
103
99
 
104
- mockClaudeImpl = async () => ({ stdout: errorClaudeJson(), exitCode: 0 });
100
+ mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
105
101
 
106
102
  await runPipeline(ctx);
107
103
 
@@ -115,7 +111,7 @@ describe("runPipeline", () => {
115
111
  let claudeCallCount = 0;
116
112
  const researchPath = join(ctx.issueDir, ARTIFACTS.research);
117
113
 
118
- mockClaudeImpl = async () => {
114
+ mockClaudeImpl = () => {
119
115
  claudeCallCount++;
120
116
  mkdirSync(ctx.issueDir, { recursive: true });
121
117
  writeFileSync(researchPath, "x".repeat(250));
@@ -130,7 +126,7 @@ describe("runPipeline", () => {
130
126
  it("stops and checks out main on step failure", async () => {
131
127
  const { runPipeline } = await import("./pipeline");
132
128
 
133
- mockClaudeImpl = async () => ({ stdout: errorClaudeJson(), exitCode: 0 });
129
+ mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
134
130
 
135
131
  await runPipeline(ctx);
136
132
 
@@ -145,7 +141,7 @@ describe("runPipeline", () => {
145
141
  const { runPipeline } = await import("./pipeline");
146
142
 
147
143
  let claudeCallCount = 0;
148
- mockClaudeImpl = async () => {
144
+ mockClaudeImpl = () => {
149
145
  claudeCallCount++;
150
146
  mkdirSync(ctx.issueDir, { recursive: true });
151
147
 
@@ -2,23 +2,15 @@ import consola from "consola";
2
2
  import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
3
 
4
4
  import { initConfig } from "./config";
5
- import { createTestRepo } from "./test-helpers";
6
- import type { TestRepo } from "./test-helpers";
5
+ import { createSpawnClaudeMock, createTestRepo } from "./test-helpers";
6
+ import type { MockClaudeImpl, TestRepo } from "./test-helpers";
7
7
 
8
8
  consola.level = -999;
9
9
 
10
- // ── File-level tinyexec mock -- intercepts all x() calls ──
10
+ let mockSpawnImpl: MockClaudeImpl = null;
11
+ vi.mock("./spawn-claude", () => createSpawnClaudeMock(() => mockSpawnImpl));
11
12
 
12
- let mockXImpl: ((...args: unknown[]) => unknown) | null = null;
13
-
14
- vi.mock("tinyexec", () => ({
15
- x: vi.fn((...args: unknown[]) => {
16
- if (mockXImpl) return mockXImpl(...args);
17
- throw new Error("mockXImpl not set");
18
- }),
19
- }));
20
-
21
- describe("runClaude (mocked tinyexec)", () => {
13
+ describe("runClaude (mocked spawn-claude)", () => {
22
14
  let originalCwd: string;
23
15
  let repo: TestRepo;
24
16
 
@@ -34,12 +26,12 @@ describe("runClaude (mocked tinyexec)", () => {
34
26
  });
35
27
 
36
28
  beforeEach(() => {
37
- mockXImpl = null;
29
+ mockSpawnImpl = null;
38
30
  vi.clearAllMocks();
39
31
  });
40
32
 
41
- it("constructs correct args and parses JSON result", async () => {
42
- mockXImpl = vi.fn().mockResolvedValue({
33
+ it("parses stream-json result event", async () => {
34
+ mockSpawnImpl = () => ({
43
35
  stdout: JSON.stringify({
44
36
  result: "All done",
45
37
  is_error: false,
@@ -63,24 +55,27 @@ describe("runClaude (mocked tinyexec)", () => {
63
55
  expect(result.is_error).toBe(false);
64
56
  expect(result.num_turns).toBe(3);
65
57
 
66
- expect(mockXImpl).toHaveBeenCalledWith(
67
- "claude",
58
+ const { spawnClaude } = await import("./spawn-claude");
59
+ expect(spawnClaude).toHaveBeenCalledWith(
68
60
  expect.arrayContaining([
69
61
  "-p",
70
62
  "--output-format",
71
- "json",
63
+ "stream-json",
64
+ "--verbose",
72
65
  "--permission-mode",
73
66
  "plan",
74
67
  "--max-turns",
75
68
  "10",
76
69
  "@test-prompt.md",
77
70
  ]),
78
- expect.any(Object),
79
71
  );
80
72
  });
81
73
 
82
- it("returns fallback when JSON parsing fails", async () => {
83
- mockXImpl = vi.fn().mockResolvedValue({ stdout: "not json output", exitCode: 0 });
74
+ it("returns fallback when no result event in stream", async () => {
75
+ mockSpawnImpl = () => ({
76
+ stdout: '{"type":"system","message":"starting"}',
77
+ exitCode: 0,
78
+ });
84
79
 
85
80
  await initConfig({ repo: "test/repo", mainBranch: "main" });
86
81
 
@@ -91,19 +86,19 @@ describe("runClaude (mocked tinyexec)", () => {
91
86
  permissionMode: "acceptEdits",
92
87
  });
93
88
 
94
- expect(result.result).toBe("not json output");
89
+ expect(result.result).toBe("");
95
90
  expect(result.is_error).toBe(false);
96
91
  expect(result.total_cost_usd).toBe(0);
97
92
  });
98
93
 
99
94
  it("retries on failure when retry is enabled", async () => {
100
95
  let callCount = 0;
101
- mockXImpl = vi.fn().mockImplementation(() => {
96
+ mockSpawnImpl = () => {
102
97
  callCount++;
103
98
  if (callCount < 3) {
104
- throw new Error("Claude process failed");
99
+ return { stdout: "", exitCode: 1 };
105
100
  }
106
- return Promise.resolve({
101
+ return {
107
102
  stdout: JSON.stringify({
108
103
  result: "ok",
109
104
  is_error: false,
@@ -111,8 +106,8 @@ describe("runClaude (mocked tinyexec)", () => {
111
106
  num_turns: 1,
112
107
  }),
113
108
  exitCode: 0,
114
- });
115
- });
109
+ };
110
+ };
116
111
 
117
112
  await initConfig({
118
113
  repo: "test/repo",
@@ -136,7 +131,7 @@ describe("runClaude (mocked tinyexec)", () => {
136
131
  }, 10_000);
137
132
 
138
133
  it("throws after max retries exhausted", async () => {
139
- mockXImpl = vi.fn().mockRejectedValue(new Error("Claude crash"));
134
+ mockSpawnImpl = () => ({ stdout: "", exitCode: 1 });
140
135
 
141
136
  await initConfig({
142
137
  repo: "test/repo",
@@ -0,0 +1,14 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { ChildProcess } from "node:child_process";
3
+
4
+ /**
5
+ * Spawns the Claude CLI as a child process.
6
+ * Extracted into its own module so tests can mock it cleanly
7
+ * without affecting other exec/spawn usage.
8
+ */
9
+ export function spawnClaude(args: string[]): ChildProcess {
10
+ return spawn("claude", args, {
11
+ cwd: process.cwd(),
12
+ stdio: ["ignore", "pipe", "inherit"],
13
+ });
14
+ }
@@ -9,44 +9,18 @@ import { initConfig } from "../config";
9
9
  import { ARTIFACTS } from "../prompt-templates/index";
10
10
  import {
11
11
  buildTestContext,
12
+ createSpawnClaudeMock,
12
13
  createTestRepoWithRemote,
13
14
  errorClaudeJson,
14
15
  successClaudeJson,
15
16
  } from "../test-helpers";
16
- import type { TestRepo } from "../test-helpers";
17
+ import type { MockClaudeImpl, TestRepo } from "../test-helpers";
17
18
  import type { IssueContext } from "../utils";
18
19
 
19
20
  consola.level = -999;
20
21
 
21
- // ── Mock tinyexec: intercept "claude" calls, pass through everything else ──
22
-
23
- let mockClaudeImpl: ((args: string[]) => Promise<{ stdout: string; exitCode: number }>) | null =
24
- null;
25
-
26
- vi.mock("tinyexec", async (importOriginal) => {
27
- const original = await importOriginal<typeof import("tinyexec")>();
28
- return {
29
- ...original,
30
- x: vi.fn(
31
- async (
32
- cmd: string,
33
- args: string[],
34
- opts?: Record<string, unknown>,
35
- ): Promise<{ stdout: string; exitCode: number }> => {
36
- if (cmd === "claude" && mockClaudeImpl) {
37
- return mockClaudeImpl(args);
38
- }
39
- if (cmd === "claude") {
40
- throw new Error("Unexpected claude call -- set mockClaudeImpl before running");
41
- }
42
- return original.x(cmd, args, opts as never) as unknown as Promise<{
43
- stdout: string;
44
- exitCode: number;
45
- }>;
46
- },
47
- ),
48
- };
49
- });
22
+ let mockClaudeImpl: MockClaudeImpl = null;
23
+ vi.mock("../spawn-claude", () => createSpawnClaudeMock(() => mockClaudeImpl));
50
24
 
51
25
  // ── Shared setup/teardown for all step tests ──
52
26
 
@@ -95,7 +69,7 @@ describe("runStepWithArtifact", () => {
95
69
  });
96
70
 
97
71
  it("returns false when Claude returns is_error", async () => {
98
- mockClaudeImpl = async () => ({ stdout: errorClaudeJson(), exitCode: 0 });
72
+ mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
99
73
 
100
74
  const { runStepWithArtifact } = await import("../utils");
101
75
  const artifactPath = join(ctx.issueDir, "missing-artifact.md");
@@ -111,7 +85,7 @@ describe("runStepWithArtifact", () => {
111
85
  });
112
86
 
113
87
  it("returns false when artifact not produced after Claude run", async () => {
114
- mockClaudeImpl = async () => ({ stdout: successClaudeJson(), exitCode: 0 });
88
+ mockClaudeImpl = () => ({ stdout: successClaudeJson(), exitCode: 0 });
115
89
 
116
90
  const { runStepWithArtifact } = await import("../utils");
117
91
  const artifactPath = join(ctx.issueDir, "never-created.md");
@@ -130,7 +104,7 @@ describe("runStepWithArtifact", () => {
130
104
  const { runStepWithArtifact } = await import("../utils");
131
105
  const artifactPath = join(ctx.issueDir, "plan.md");
132
106
 
133
- mockClaudeImpl = async () => {
107
+ mockClaudeImpl = () => {
134
108
  writeFileSync(artifactPath, "# Plan\n\nDetailed plan content here.");
135
109
  return { stdout: successClaudeJson(), exitCode: 0 };
136
110
  };
@@ -179,7 +153,7 @@ describe("stepResearch", () => {
179
153
  writeFileSync(researchPath, "short");
180
154
 
181
155
  let claudeCalled = false;
182
- mockClaudeImpl = async () => {
156
+ mockClaudeImpl = () => {
183
157
  claudeCalled = true;
184
158
  writeFileSync(researchPath, "x".repeat(250));
185
159
  return { stdout: successClaudeJson(), exitCode: 0 };
@@ -194,7 +168,7 @@ describe("stepResearch", () => {
194
168
  const { stepResearch } = await import("./research");
195
169
 
196
170
  const researchPath = join(ctx.issueDir, ARTIFACTS.research);
197
- mockClaudeImpl = async () => {
171
+ mockClaudeImpl = () => {
198
172
  writeFileSync(researchPath, "x".repeat(250));
199
173
  return { stdout: successClaudeJson(), exitCode: 0 };
200
174
  };
@@ -244,7 +218,7 @@ describe("stepPlanAnnotations", () => {
244
218
  const addressedPath = join(ctx.issueDir, ARTIFACTS.planAnnotationsAddressed);
245
219
  writeFileSync(annotationsPath, "# Annotations\n\nSome feedback here.");
246
220
 
247
- mockClaudeImpl = async () => ({ stdout: successClaudeJson(), exitCode: 0 });
221
+ mockClaudeImpl = () => ({ stdout: successClaudeJson(), exitCode: 0 });
248
222
 
249
223
  const result = await stepPlanAnnotations(ctx);
250
224
 
@@ -288,7 +262,7 @@ describe("stepImplement", () => {
288
262
  const { stepImplement } = await import("./implement");
289
263
 
290
264
  let callCount = 0;
291
- mockClaudeImpl = async () => {
265
+ mockClaudeImpl = () => {
292
266
  callCount++;
293
267
  return { stdout: successClaudeJson(), exitCode: 0 };
294
268
  };
@@ -304,7 +278,7 @@ describe("stepImplement", () => {
304
278
  let callCount = 0;
305
279
  const completedPath = join(ctx.issueDir, ARTIFACTS.completedSummary);
306
280
 
307
- mockClaudeImpl = async () => {
281
+ mockClaudeImpl = () => {
308
282
  callCount++;
309
283
  if (callCount === 2) {
310
284
  writeFileSync(completedPath, "# Implementation Complete\n\nAll tasks done.");
@@ -1,7 +1,12 @@
1
+ import type { ChildProcess } from "node:child_process";
1
2
  import { execSync } from "node:child_process";
3
+ import { EventEmitter } from "node:events";
2
4
  import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
5
  import { tmpdir } from "node:os";
4
6
  import { join } from "node:path";
7
+ import { PassThrough } from "node:stream";
8
+
9
+ import { vi } from "vitest";
5
10
 
6
11
  import type { ClaudeResult, IssueContext } from "./utils";
7
12
 
@@ -62,7 +67,7 @@ export function buildTestContext(dir: string, issueNumber = 1): IssueContext {
62
67
  }
63
68
 
64
69
  /**
65
- * Returns a JSON string matching a successful ClaudeResult.
70
+ * Returns a stream-json result line for a successful ClaudeResult.
66
71
  */
67
72
  export function successClaudeJson(result = "done"): string {
68
73
  return JSON.stringify({
@@ -74,7 +79,7 @@ export function successClaudeJson(result = "done"): string {
74
79
  }
75
80
 
76
81
  /**
77
- * Returns a JSON string matching a failed ClaudeResult.
82
+ * Returns a stream-json result line for a failed ClaudeResult.
78
83
  */
79
84
  export function errorClaudeJson(result = "failed"): string {
80
85
  return JSON.stringify({
@@ -84,3 +89,51 @@ export function errorClaudeJson(result = "failed"): string {
84
89
  num_turns: 0,
85
90
  } satisfies ClaudeResult);
86
91
  }
92
+
93
+ /**
94
+ * Creates a fake ChildProcess with PassThrough stdout for testing
95
+ * the streaming runClaude implementation.
96
+ *
97
+ * Writes `stdout` content to the stream, then emits `close` with `exitCode`.
98
+ */
99
+ export function createMockClaudeProcess(stdout: string, exitCode = 0): ChildProcess {
100
+ const proc = new EventEmitter() as ChildProcess;
101
+ const stdoutStream = new PassThrough();
102
+ (proc as unknown as { stdout: PassThrough }).stdout = stdoutStream;
103
+ (proc as unknown as { stderr: null }).stderr = null;
104
+ (proc as unknown as { stdin: null }).stdin = null;
105
+
106
+ // Write and close asynchronously so listeners can attach first
107
+ queueMicrotask(() => {
108
+ if (stdout) {
109
+ stdoutStream.write(`${stdout}\n`);
110
+ }
111
+ stdoutStream.end();
112
+ proc.emit("close", exitCode);
113
+ });
114
+
115
+ return proc;
116
+ }
117
+
118
+ export type MockClaudeImpl = ((args: string[]) => { stdout: string; exitCode: number }) | null;
119
+
120
+ /**
121
+ * Creates the mock object for vi.mock("./spawn-claude").
122
+ * Must be called inside the vi.mock factory arrow (lazy) to avoid hoisting issues.
123
+ *
124
+ * Usage:
125
+ * let mockClaudeImpl: MockClaudeImpl = null;
126
+ * vi.mock("./spawn-claude", () => createSpawnClaudeMock(() => mockClaudeImpl));
127
+ */
128
+ export function createSpawnClaudeMock(getImpl: () => MockClaudeImpl) {
129
+ return {
130
+ spawnClaude: vi.fn((args: string[]) => {
131
+ const impl = getImpl();
132
+ if (impl) {
133
+ const { stdout, exitCode } = impl(args);
134
+ return createMockClaudeProcess(stdout, exitCode);
135
+ }
136
+ throw new Error("Unexpected spawnClaude call -- set mockClaudeImpl");
137
+ }),
138
+ };
139
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, relative } from "node:path";
3
+ import { createInterface } from "node:readline";
3
4
  import { fileURLToPath } from "node:url";
4
5
 
5
6
  import consola from "consola";
@@ -9,6 +10,7 @@ import { x } from "tinyexec";
9
10
  import { createBranchNameFromIssue } from "../../utils/git/branch-name.js";
10
11
  import { getConfig } from "./config.js";
11
12
  import { ARTIFACTS } from "./prompt-templates/index.js";
13
+ import { spawnClaude } from "./spawn-claude.js";
12
14
 
13
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
16
  export const TEMPLATES_DIR = join(__dirname, "prompt-templates");
@@ -64,7 +66,8 @@ export async function runClaude(opts: {
64
66
  const args = [
65
67
  "-p",
66
68
  "--output-format",
67
- "json",
69
+ "stream-json",
70
+ "--verbose",
68
71
  "--permission-mode",
69
72
  opts.permissionMode,
70
73
  ...(opts.maxTurns ? ["--max-turns", String(opts.maxTurns)] : []),
@@ -77,26 +80,12 @@ export async function runClaude(opts: {
77
80
 
78
81
  while (true) {
79
82
  try {
80
- const proc = await x("claude", args, {
81
- nodeOptions: { cwd: process.cwd(), stdio: ["ignore", "pipe", "inherit"] },
82
- throwOnError: true,
83
- });
84
- const stdout = proc.stdout;
85
-
86
- try {
87
- const parsed = JSON.parse(stdout) as ClaudeResult;
88
- consola.success(`Done — ${parsed.num_turns} turns`);
89
- if (parsed.result) {
90
- consola.log(parsed.result);
91
- }
92
- return parsed;
93
- } catch {
94
- consola.warn("Done — failed to parse Claude output");
95
- if (stdout.trim()) {
96
- consola.log(stdout.trim());
97
- }
98
- return { result: stdout.trim(), is_error: false, total_cost_usd: 0, num_turns: 0 };
83
+ const result = await runClaudeStreaming(args);
84
+ consola.success(`Done ${result.num_turns} turns`);
85
+ if (result.result) {
86
+ consola.log(result.result);
99
87
  }
88
+ return result;
100
89
  } catch (e) {
101
90
  const shouldRetry = opts.retry ?? cfg.loopRetryEnabled ?? false;
102
91
  if (!shouldRetry) throw e;
@@ -114,6 +103,94 @@ export async function runClaude(opts: {
114
103
  }
115
104
  }
116
105
 
106
+ function runClaudeStreaming(args: string[]): Promise<ClaudeResult> {
107
+ return new Promise((resolve, reject) => {
108
+ const proc = spawnClaude(args);
109
+ let capturedResult: ClaudeResult | null = null;
110
+ let turnCount = 0;
111
+
112
+ if (!proc.stdout) {
113
+ reject(new Error("Claude process has no stdout"));
114
+ return;
115
+ }
116
+
117
+ const rl = createInterface({ input: proc.stdout });
118
+
119
+ rl.on("line", (line) => {
120
+ if (!line.trim()) return;
121
+ try {
122
+ const event = JSON.parse(line) as Record<string, unknown>;
123
+ handleStreamEvent(event, (turns) => {
124
+ turnCount = turns;
125
+ });
126
+
127
+ if ("result" in event && "is_error" in event && "num_turns" in event) {
128
+ capturedResult = {
129
+ result: String(event.result ?? ""),
130
+ is_error: Boolean(event.is_error),
131
+ total_cost_usd: Number(event.total_cost_usd ?? 0),
132
+ num_turns: Number(event.num_turns),
133
+ };
134
+ }
135
+ } catch {
136
+ // Skip non-JSON lines
137
+ }
138
+ });
139
+
140
+ proc.on("error", (err) => {
141
+ rl.close();
142
+ reject(err);
143
+ });
144
+
145
+ proc.on("close", (code) => {
146
+ rl.close();
147
+ if (capturedResult) {
148
+ resolve(capturedResult);
149
+ } else if (code !== 0) {
150
+ reject(new Error(`Claude process exited with code ${code}`));
151
+ } else {
152
+ resolve({ result: "", is_error: false, total_cost_usd: 0, num_turns: turnCount });
153
+ }
154
+ });
155
+ });
156
+ }
157
+
158
+ function handleStreamEvent(event: Record<string, unknown>, onTurn: (count: number) => void): void {
159
+ // Tool use events: look for content blocks with tool_use type
160
+ if (event.type === "assistant" && Array.isArray(event.message)) {
161
+ for (const block of event.message) {
162
+ if (
163
+ typeof block === "object" &&
164
+ block !== null &&
165
+ "type" in block &&
166
+ (block as Record<string, unknown>).type === "tool_use"
167
+ ) {
168
+ const name = (block as Record<string, unknown>).name;
169
+ if (typeof name === "string") {
170
+ consola.info(` ${pc.dim("\u21B3")} ${name}`);
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ // Content block with tool_use (alternative format)
177
+ if (
178
+ event.type === "content_block_start" &&
179
+ typeof event.content_block === "object" &&
180
+ event.content_block !== null
181
+ ) {
182
+ const block = event.content_block as Record<string, unknown>;
183
+ if (block.type === "tool_use" && typeof block.name === "string") {
184
+ consola.info(` ${pc.dim("\u21B3")} ${block.name}`);
185
+ }
186
+ }
187
+
188
+ // Turn count tracking
189
+ if (typeof event.num_turns === "number" && !("result" in event)) {
190
+ onTurn(event.num_turns as number);
191
+ }
192
+ }
193
+
117
194
  // ── Template resolution ──
118
195
 
119
196
  export interface TokenValues {
@@ -1,26 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { isGitDirectory } from "./git-wrapper";
3
-
4
- describe.skip("git-wrapper", () => {
5
- it("isGitDirectory", async () => {
6
- const result = await isGitDirectory();
7
- expect(result).toBe(true);
8
- });
9
-
10
- // it('getMergedBranches', async () => {
11
- // const result = await getMergedBranches()
12
-
13
- // if (result.length > 0) // may not have branches to cleanup so just check for no errors
14
- // expect(result[0].trim()).toBe(result[0])
15
- // })
16
-
17
- // it('getLocalBranchNames', async () => {
18
- // const result = await getLocalBranchNames()
19
- // expect(result.includes('main')).toBeDefined()
20
- // })
21
-
22
- // it('getDefaultMainBranchName', async () => {
23
- // const result = await getDefaultMainBranchName()
24
- // expect(result).toBe('main')
25
- // })
26
- });
@@ -1,15 +0,0 @@
1
- import { exec } from "tinyexec";
2
-
3
- export const isGitDirectory = async (): Promise<boolean> => {
4
- try {
5
- const result = await exec(`git`, ["status"]);
6
- return result.stdout.includes("On branch");
7
- } catch (e) {
8
- return false;
9
- }
10
- };
11
-
12
- export const createBranch = async ({ branchName }: { branchName: string }): Promise<string> => {
13
- const result = await exec(`git`, ["checkout", "-b", branchName]);
14
- return result.stdout;
15
- };