@towles/tool 0.0.66 → 0.0.68

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.
@@ -8,16 +8,29 @@ import { createPr } from "./steps/create-pr.js";
8
8
  import { stepImplement } from "./steps/implement.js";
9
9
  import { stepPlan, stepReview, stepSimplify } from "./steps/simple-steps.js";
10
10
  import { LABELS, ensureLabelsExist, removeLabel, setLabel } from "./labels.js";
11
+ import type { ExecSafeFn } from "./labels.js";
11
12
  import { ensureDir, fileExists, readFile, writeFile } from "../../utils/fs.js";
12
13
  import { execSafe, git } from "../../utils/git/exec.js";
13
14
  import { ghRaw } from "../../utils/git/gh-cli-wrapper.js";
14
15
  import { log } from "./utils.js";
15
16
  import type { IssueContext } from "./utils.js";
17
+ import type { SpawnClaudeFn } from "./spawn-claude.js";
16
18
 
17
19
  export { type StepName, STEP_NAMES } from "./prompt-templates/index.js";
18
20
 
19
- export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Promise<void> {
21
+ export interface PipelineDeps {
22
+ spawnFn?: SpawnClaudeFn;
23
+ exec?: ExecSafeFn;
24
+ }
25
+
26
+ export async function runPipeline(
27
+ ctx: IssueContext,
28
+ untilStep?: StepName,
29
+ deps?: PipelineDeps,
30
+ ): Promise<void> {
20
31
  const cfg = getConfig();
32
+ const exec = deps?.exec;
33
+ const spawnFn = deps?.spawnFn;
21
34
  log(`Pipeline starting for ${ctx.repo}#${ctx.number}: ${ctx.title}`);
22
35
 
23
36
  ensureDir(ctx.issueDir);
@@ -29,14 +42,14 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
29
42
  }
30
43
 
31
44
  // Label management
32
- await ensureLabelsExist(ctx.repo);
33
- await removeLabel(ctx.repo, ctx.number, cfg.triggerLabel);
34
- await setLabel(ctx.repo, ctx.number, LABELS.inProgress);
45
+ await ensureLabelsExist(ctx.repo, exec);
46
+ await removeLabel(ctx.repo, ctx.number, cfg.triggerLabel, exec);
47
+ await setLabel(ctx.repo, ctx.number, LABELS.inProgress, exec);
35
48
 
36
49
  try {
37
50
  // Step 1: Plan (runs once)
38
- if (!(await stepPlan(ctx))) {
39
- await handleFailure(ctx, "plan");
51
+ if (!(await stepPlan(ctx, spawnFn))) {
52
+ await handleFailure(ctx, "plan", undefined, exec);
40
53
  return;
41
54
  }
42
55
  if (untilStep === "plan") {
@@ -55,8 +68,8 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
55
68
  }
56
69
 
57
70
  // Implement
58
- if (!(await stepImplement(ctx))) {
59
- await handleFailure(ctx, "implement");
71
+ if (!(await stepImplement(ctx, spawnFn))) {
72
+ await handleFailure(ctx, "implement", undefined, exec);
60
73
  return;
61
74
  }
62
75
  if (untilStep === "implement") {
@@ -65,8 +78,8 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
65
78
  }
66
79
 
67
80
  // Simplify
68
- if (!(await stepSimplify(ctx))) {
69
- await handleFailure(ctx, "simplify");
81
+ if (!(await stepSimplify(ctx, spawnFn))) {
82
+ await handleFailure(ctx, "simplify", undefined, exec);
70
83
  return;
71
84
  }
72
85
  if (untilStep === "simplify") {
@@ -75,8 +88,8 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
75
88
  }
76
89
 
77
90
  // Review
78
- if (!(await stepReview(ctx))) {
79
- await handleFailure(ctx, "review");
91
+ if (!(await stepReview(ctx, spawnFn))) {
92
+ await handleFailure(ctx, "review", undefined, exec);
80
93
  return;
81
94
  }
82
95
  if (untilStep === "review") {
@@ -86,10 +99,10 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
86
99
 
87
100
  // Check review result
88
101
  if (isReviewPass(ctx)) {
89
- const prUrl = await createPr(ctx);
90
- await removeLabel(ctx.repo, ctx.number, LABELS.inProgress);
91
- await setLabel(ctx.repo, ctx.number, LABELS.success);
92
- await setLabel(ctx.repo, ctx.number, LABELS.review);
102
+ const prUrl = await createPr(ctx, exec);
103
+ await removeLabel(ctx.repo, ctx.number, LABELS.inProgress, exec);
104
+ await setLabel(ctx.repo, ctx.number, LABELS.success, exec);
105
+ await setLabel(ctx.repo, ctx.number, LABELS.review, exec);
93
106
  log(`Pipeline complete for ${ctx.repo}#${ctx.number} — ${prUrl}`);
94
107
  return;
95
108
  }
@@ -107,6 +120,7 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
107
120
  ctx,
108
121
  "review",
109
122
  `auto-claude: review did not pass after ${maxRetries + 1} attempts. Labelled \`${LABELS.failed}\`.`,
123
+ exec,
110
124
  );
111
125
  } finally {
112
126
  await checkoutMain();
@@ -125,11 +139,19 @@ function isReviewPass(ctx: IssueContext): boolean {
125
139
  return firstLine === "PASS";
126
140
  }
127
141
 
128
- async function handleFailure(ctx: IssueContext, stepName: string, comment?: string): Promise<void> {
129
- await removeLabel(ctx.repo, ctx.number, LABELS.inProgress);
130
- await setLabel(ctx.repo, ctx.number, LABELS.failed);
142
+ async function handleFailure(
143
+ ctx: IssueContext,
144
+ stepName: string,
145
+ comment?: string,
146
+ exec?: ExecSafeFn,
147
+ ): Promise<void> {
148
+ await removeLabel(ctx.repo, ctx.number, LABELS.inProgress, exec);
149
+ await setLabel(ctx.repo, ctx.number, LABELS.failed, exec);
131
150
  if (comment) {
132
- await ghRaw(["issue", "comment", String(ctx.number), "--repo", ctx.repo, "--body", comment]);
151
+ await ghRaw(
152
+ ["issue", "comment", String(ctx.number), "--repo", ctx.repo, "--body", comment],
153
+ exec,
154
+ );
133
155
  }
134
156
  log(`Pipeline stopped at "${stepName}" for ${ctx.repo}#${ctx.number}`);
135
157
  }
@@ -2,15 +2,14 @@ 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 { createSpawnClaudeMock, createTestRepo } from "./test-helpers";
6
- import type { MockClaudeImpl, TestRepo } from "./test-helpers";
5
+ import { createMockClaudeProcess, createTestRepo } from "./test-helpers";
6
+ import type { TestRepo } from "./test-helpers";
7
+ import type { SpawnClaudeFn } from "./spawn-claude";
8
+ import type { ClaudeLogger } from "./claude-cli";
7
9
 
8
10
  consola.level = -999;
9
11
 
10
- let mockSpawnImpl: MockClaudeImpl = null;
11
- vi.mock("./spawn-claude", () => createSpawnClaudeMock(() => mockSpawnImpl));
12
-
13
- describe("runClaude (mocked spawn-claude)", () => {
12
+ describe("runClaude (injected spawnFn)", () => {
14
13
  let originalCwd: string;
15
14
  let repo: TestRepo;
16
15
 
@@ -26,19 +25,20 @@ describe("runClaude (mocked spawn-claude)", () => {
26
25
  });
27
26
 
28
27
  beforeEach(() => {
29
- mockSpawnImpl = null;
30
28
  vi.clearAllMocks();
31
29
  });
32
30
 
33
31
  it("parses stream-json result event", async () => {
34
- mockSpawnImpl = () => ({
35
- stdout: JSON.stringify({
36
- result: "All done",
37
- is_error: false,
38
- total_cost_usd: 0.05,
39
- num_turns: 3,
40
- }),
41
- exitCode: 0,
32
+ const mockSpawnClaude: SpawnClaudeFn = vi.fn((args: string[]) => {
33
+ return createMockClaudeProcess(
34
+ JSON.stringify({
35
+ result: "All done",
36
+ is_error: false,
37
+ total_cost_usd: 0.05,
38
+ num_turns: 3,
39
+ }),
40
+ 0,
41
+ );
42
42
  });
43
43
 
44
44
  await initConfig({ repo: "test/repo", mainBranch: "main" });
@@ -48,14 +48,14 @@ describe("runClaude (mocked spawn-claude)", () => {
48
48
  const result = await runClaude({
49
49
  promptFile: "test-prompt.md",
50
50
  maxTurns: 10,
51
+ spawnFn: mockSpawnClaude,
51
52
  });
52
53
 
53
54
  expect(result.result).toBe("All done");
54
55
  expect(result.is_error).toBe(false);
55
56
  expect(result.num_turns).toBe(3);
56
57
 
57
- const { spawnClaude } = await import("./spawn-claude");
58
- expect(spawnClaude).toHaveBeenCalledWith(
58
+ expect(mockSpawnClaude).toHaveBeenCalledWith(
59
59
  expect.arrayContaining([
60
60
  "-p",
61
61
  "--output-format",
@@ -71,7 +71,13 @@ describe("runClaude (mocked spawn-claude)", () => {
71
71
  });
72
72
 
73
73
  it("logs tool names from stream_event and shows thinking indicator", async () => {
74
- const infoSpy = vi.spyOn(consola, "info");
74
+ const mockLogger: ClaudeLogger = {
75
+ info: vi.fn(),
76
+ warn: vi.fn(),
77
+ error: vi.fn(),
78
+ success: vi.fn(),
79
+ log: vi.fn(),
80
+ };
75
81
 
76
82
  const thinkingEvent = {
77
83
  type: "stream_event",
@@ -94,30 +100,35 @@ describe("runClaude (mocked spawn-claude)", () => {
94
100
  num_turns: 1,
95
101
  };
96
102
 
97
- mockSpawnImpl = () => ({
98
- stdout: [thinkingEvent, toolEvent, resultEvent].map((e) => JSON.stringify(e)).join("\n"),
99
- exitCode: 0,
103
+ const mockSpawnClaude: SpawnClaudeFn = vi.fn(() => {
104
+ return createMockClaudeProcess(
105
+ [thinkingEvent, toolEvent, resultEvent].map((e) => JSON.stringify(e)).join("\n"),
106
+ 0,
107
+ );
100
108
  });
101
109
 
102
110
  await initConfig({ repo: "test/repo", mainBranch: "main" });
103
111
 
104
112
  const { runClaude } = await import("./claude-cli");
105
- const result = await runClaude({ promptFile: "test.md" });
113
+ const result = await runClaude({
114
+ promptFile: "test.md",
115
+ spawnFn: mockSpawnClaude,
116
+ logger: mockLogger,
117
+ });
106
118
 
107
119
  expect(result.result).toBe("Done");
108
120
  expect(result.num_turns).toBe(1);
109
121
 
110
- const infoCalls = infoSpy.mock.calls.map((c) => String(c[0]));
122
+ const infoCalls = (mockLogger.info as ReturnType<typeof vi.fn>).mock.calls.map((c) =>
123
+ String(c[0]),
124
+ );
111
125
  expect(infoCalls.some((msg) => msg.includes("thinking"))).toBe(true);
112
126
  expect(infoCalls.some((msg) => msg.includes("Edit"))).toBe(true);
113
-
114
- infoSpy.mockRestore();
115
127
  });
116
128
 
117
129
  it("returns fallback when no result event in stream", async () => {
118
- mockSpawnImpl = () => ({
119
- stdout: '{"type":"system","message":"starting"}',
120
- exitCode: 0,
130
+ const mockSpawnClaude: SpawnClaudeFn = vi.fn(() => {
131
+ return createMockClaudeProcess('{"type":"system","message":"starting"}', 0);
121
132
  });
122
133
 
123
134
  await initConfig({ repo: "test/repo", mainBranch: "main" });
@@ -126,6 +137,7 @@ describe("runClaude (mocked spawn-claude)", () => {
126
137
 
127
138
  const result = await runClaude({
128
139
  promptFile: "test.md",
140
+ spawnFn: mockSpawnClaude,
129
141
  });
130
142
 
131
143
  expect(result.result).toBe("");
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import type { ChildProcess } from "node:child_process";
3
3
 
4
+ export type SpawnClaudeFn = (args: string[]) => ChildProcess;
5
+
4
6
  /**
5
7
  * Spawns the Claude CLI as a child process.
6
8
  * Extracted into its own module so tests can mock it cleanly
@@ -9,11 +9,12 @@ import { getConfig } from "../config.js";
9
9
  import { ARTIFACTS } from "../prompt-templates/index.js";
10
10
  import { log } from "../utils.js";
11
11
  import type { IssueContext } from "../utils.js";
12
+ import type { ExecSafeFn } from "../labels.js";
12
13
 
13
- export async function createPr(ctx: IssueContext): Promise<string> {
14
+ export async function createPr(ctx: IssueContext, exec?: ExecSafeFn): Promise<string> {
14
15
  const cfg = getConfig();
15
16
 
16
- const existingUrl = await getExistingPrUrl(ctx.branch);
17
+ const existingUrl = await getExistingPrUrl(ctx.branch, exec);
17
18
  if (existingUrl) {
18
19
  log(`PR already exists: ${existingUrl}`);
19
20
  return existingUrl;
@@ -48,18 +49,21 @@ export async function createPr(ctx: IssueContext): Promise<string> {
48
49
  "Generated by auto-claude pipeline",
49
50
  ].join("\n");
50
51
 
51
- const prUrl = await ghRaw([
52
- "pr",
53
- "create",
54
- "--repo",
55
- cfg.repo,
56
- "--head",
57
- ctx.branch,
58
- "--title",
59
- ctx.title,
60
- "--body",
61
- body,
62
- ]);
52
+ const prUrl = await ghRaw(
53
+ [
54
+ "pr",
55
+ "create",
56
+ "--repo",
57
+ cfg.repo,
58
+ "--head",
59
+ ctx.branch,
60
+ "--title",
61
+ ctx.title,
62
+ "--body",
63
+ body,
64
+ ],
65
+ exec,
66
+ );
63
67
 
64
68
  if (!prUrl) {
65
69
  throw new Error("Failed to create PR — gh returned empty output");
@@ -69,7 +73,7 @@ export async function createPr(ctx: IssueContext): Promise<string> {
69
73
  log(`PR created: ${prUrl}`);
70
74
 
71
75
  try {
72
- await attachArtifacts(ctx, prUrl);
76
+ await attachArtifacts(ctx, prUrl, exec);
73
77
  } catch (e) {
74
78
  consola.warn(`Artifact upload failed (non-blocking): ${e}`);
75
79
  }
@@ -77,7 +81,7 @@ export async function createPr(ctx: IssueContext): Promise<string> {
77
81
  return prUrl;
78
82
  }
79
83
 
80
- async function attachArtifacts(ctx: IssueContext, prUrl: string): Promise<void> {
84
+ async function attachArtifacts(ctx: IssueContext, prUrl: string, exec?: ExecSafeFn): Promise<void> {
81
85
  const archivePath = join(ctx.issueDir, "artifacts.tar.gz");
82
86
  const tag = `ac-issue-${ctx.number}`;
83
87
  const cfg = getConfig();
@@ -94,31 +98,27 @@ async function attachArtifacts(ctx: IssueContext, prUrl: string): Promise<void>
94
98
  ]);
95
99
 
96
100
  log("Uploading artifacts to GitHub release…");
97
- await ghRaw(["release", "delete", tag, "--yes", "--repo", cfg.repo]);
98
-
99
- await ghRaw([
100
- "release",
101
- "create",
102
- tag,
103
- "--prerelease",
104
- "--title",
105
- `Artifacts: #${ctx.number}`,
106
- "--repo",
107
- cfg.repo,
108
- archivePath,
109
- ]);
110
-
111
- const assetUrl = await ghRaw([
112
- "release",
113
- "view",
114
- tag,
115
- "--json",
116
- "assets",
117
- "--jq",
118
- ".assets[0].url",
119
- "--repo",
120
- cfg.repo,
121
- ]);
101
+ await ghRaw(["release", "delete", tag, "--yes", "--repo", cfg.repo], exec);
102
+
103
+ await ghRaw(
104
+ [
105
+ "release",
106
+ "create",
107
+ tag,
108
+ "--prerelease",
109
+ "--title",
110
+ `Artifacts: #${ctx.number}`,
111
+ "--repo",
112
+ cfg.repo,
113
+ archivePath,
114
+ ],
115
+ exec,
116
+ );
117
+
118
+ const assetUrl = await ghRaw(
119
+ ["release", "view", tag, "--json", "assets", "--jq", ".assets[0].url", "--repo", cfg.repo],
120
+ exec,
121
+ );
122
122
 
123
123
  if (!assetUrl) {
124
124
  consola.warn("Could not get artifact download URL — skipping PR comment");
@@ -133,22 +133,25 @@ async function attachArtifacts(ctx: IssueContext, prUrl: string): Promise<void>
133
133
  "Contains: plan.md, completed-summary.md, simplify-summary.md, review.md",
134
134
  ].join("\n");
135
135
 
136
- await ghRaw(["pr", "comment", prUrl, "--body", comment]);
136
+ await ghRaw(["pr", "comment", prUrl, "--body", comment], exec);
137
137
  }
138
138
 
139
- async function getExistingPrUrl(branch: string): Promise<string | null> {
140
- const out = await ghRaw([
141
- "pr",
142
- "list",
143
- "--repo",
144
- getConfig().repo,
145
- "--head",
146
- branch,
147
- "--state",
148
- "open",
149
- "--json",
150
- "url",
151
- ]);
139
+ async function getExistingPrUrl(branch: string, exec?: ExecSafeFn): Promise<string | null> {
140
+ const out = await ghRaw(
141
+ [
142
+ "pr",
143
+ "list",
144
+ "--repo",
145
+ getConfig().repo,
146
+ "--head",
147
+ branch,
148
+ "--state",
149
+ "open",
150
+ "--json",
151
+ "url",
152
+ ],
153
+ exec,
154
+ );
152
155
  try {
153
156
  const prs = JSON.parse(out) as Array<{ url: string }>;
154
157
  return prs.length > 0 ? prs[0].url : null;
@@ -10,8 +10,9 @@ import { runClaude } from "../claude-cli.js";
10
10
  import { resolveTemplate } from "../templates.js";
11
11
  import { buildTokens, log, logStep } from "../utils.js";
12
12
  import type { IssueContext } from "../utils.js";
13
+ import type { SpawnClaudeFn } from "../spawn-claude.js";
13
14
 
14
- export async function stepImplement(ctx: IssueContext): Promise<boolean> {
15
+ export async function stepImplement(ctx: IssueContext, spawnFn?: SpawnClaudeFn): Promise<boolean> {
15
16
  const completedPath = join(ctx.issueDir, ARTIFACTS.completedSummary);
16
17
  const maxIterations = getConfig().maxImplementIterations;
17
18
 
@@ -36,6 +37,7 @@ export async function stepImplement(ctx: IssueContext): Promise<boolean> {
36
37
  const result = await runClaude({
37
38
  promptFile,
38
39
  maxTurns: getConfig().maxTurns,
40
+ spawnFn,
39
41
  });
40
42
 
41
43
  if (result.is_error) {
@@ -3,8 +3,9 @@ import { join } from "node:path";
3
3
  import { ARTIFACTS, STEP_LABELS, TEMPLATES } from "../prompt-templates/index.js";
4
4
  import { ensureBranch, runStepWithArtifact } from "../utils.js";
5
5
  import type { IssueContext } from "../utils.js";
6
+ import type { SpawnClaudeFn } from "../spawn-claude.js";
6
7
 
7
- export async function stepPlan(ctx: IssueContext): Promise<boolean> {
8
+ export async function stepPlan(ctx: IssueContext, spawnFn?: SpawnClaudeFn): Promise<boolean> {
8
9
  await ensureBranch(ctx.branch);
9
10
 
10
11
  return runStepWithArtifact({
@@ -12,23 +13,26 @@ export async function stepPlan(ctx: IssueContext): Promise<boolean> {
12
13
  ctx,
13
14
  artifactPath: join(ctx.issueDir, ARTIFACTS.plan),
14
15
  templateName: TEMPLATES.plan,
16
+ spawnFn,
15
17
  });
16
18
  }
17
19
 
18
- export async function stepSimplify(ctx: IssueContext): Promise<boolean> {
20
+ export async function stepSimplify(ctx: IssueContext, spawnFn?: SpawnClaudeFn): Promise<boolean> {
19
21
  return runStepWithArtifact({
20
22
  stepName: STEP_LABELS.simplify,
21
23
  ctx,
22
24
  artifactPath: join(ctx.issueDir, ARTIFACTS.simplifySummary),
23
25
  templateName: TEMPLATES.simplify,
26
+ spawnFn,
24
27
  });
25
28
  }
26
29
 
27
- export async function stepReview(ctx: IssueContext): Promise<boolean> {
30
+ export async function stepReview(ctx: IssueContext, spawnFn?: SpawnClaudeFn): Promise<boolean> {
28
31
  return runStepWithArtifact({
29
32
  stepName: STEP_LABELS.review,
30
33
  ctx,
31
34
  artifactPath: join(ctx.issueDir, ARTIFACTS.review),
32
35
  templateName: TEMPLATES.review,
36
+ spawnFn,
33
37
  });
34
38
  }