@towles/tool 0.0.65 → 0.0.67

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.
@@ -1,22 +1,21 @@
1
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
1
  import { describe, expect, it, vi, beforeEach } from "vitest";
3
2
 
4
3
  import { resolveTemplate } from "./templates";
5
- import type { TokenValues } from "./templates";
4
+ import type { TemplateFsDeps, TokenValues } from "./templates";
6
5
 
7
- vi.mock("node:fs", () => ({
8
- readFileSync: vi.fn(),
9
- writeFileSync: vi.fn(),
10
- mkdirSync: vi.fn(),
11
- }));
12
-
13
- const mockedReadFileSync = vi.mocked(readFileSync);
14
- const mockedWriteFileSync = vi.mocked(writeFileSync);
15
- const mockedMkdirSync = vi.mocked(mkdirSync);
6
+ function createMockFs(): TemplateFsDeps {
7
+ return {
8
+ readFileSync: vi.fn().mockReturnValue(""),
9
+ writeFileSync: vi.fn(),
10
+ mkdirSync: vi.fn(),
11
+ };
12
+ }
16
13
 
17
14
  describe("resolveTemplate", () => {
15
+ let mockFs: TemplateFsDeps;
16
+
18
17
  beforeEach(() => {
19
- vi.clearAllMocks();
18
+ mockFs = createMockFs();
20
19
  });
21
20
 
22
21
  const tokens: TokenValues = {
@@ -26,13 +25,13 @@ describe("resolveTemplate", () => {
26
25
  };
27
26
 
28
27
  it("replaces all token placeholders in template", () => {
29
- mockedReadFileSync.mockReturnValue(
28
+ vi.mocked(mockFs.readFileSync).mockReturnValue(
30
29
  "Scope: {{SCOPE_PATH}}\nDir: {{ISSUE_DIR}}\nBranch: {{MAIN_BRANCH}}",
31
30
  );
32
31
 
33
- resolveTemplate("plan.md", tokens, "/tmp/issues/42");
32
+ resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
34
33
 
35
- const writtenContent = mockedWriteFileSync.mock.calls[0][1];
34
+ const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
36
35
  expect(writtenContent).toContain("/home/user/project");
37
36
  expect(writtenContent).toContain("/tmp/issues/42");
38
37
  expect(writtenContent).toContain("main");
@@ -44,28 +43,28 @@ describe("resolveTemplate", () => {
44
43
  ...tokens,
45
44
  REVIEW_FEEDBACK: "Needs more tests",
46
45
  };
47
- mockedReadFileSync.mockReturnValue("Feedback: {{REVIEW_FEEDBACK}}");
46
+ vi.mocked(mockFs.readFileSync).mockReturnValue("Feedback: {{REVIEW_FEEDBACK}}");
48
47
 
49
- resolveTemplate("review.md", tokensWithFeedback, "/tmp/issues/42");
48
+ resolveTemplate("review.md", tokensWithFeedback, "/tmp/issues/42", mockFs);
50
49
 
51
- const writtenContent = mockedWriteFileSync.mock.calls[0][1];
50
+ const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
52
51
  expect(writtenContent).toContain("Needs more tests");
53
52
  });
54
53
 
55
54
  it("creates output directory recursively", () => {
56
- mockedReadFileSync.mockReturnValue("template content");
55
+ vi.mocked(mockFs.readFileSync).mockReturnValue("template content");
57
56
 
58
- resolveTemplate("plan.md", tokens, "/tmp/issues/42");
57
+ resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
59
58
 
60
- expect(mockedMkdirSync).toHaveBeenCalledWith("/tmp/issues/42", { recursive: true });
59
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith("/tmp/issues/42", { recursive: true });
61
60
  });
62
61
 
63
62
  it("writes resolved template to issue dir", () => {
64
- mockedReadFileSync.mockReturnValue("simple content");
63
+ vi.mocked(mockFs.readFileSync).mockReturnValue("simple content");
65
64
 
66
- resolveTemplate("plan.md", tokens, "/tmp/issues/42");
65
+ resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
67
66
 
68
- expect(mockedWriteFileSync).toHaveBeenCalledWith(
67
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
69
68
  "/tmp/issues/42/plan.md",
70
69
  "simple content",
71
70
  "utf-8",
@@ -73,19 +72,19 @@ describe("resolveTemplate", () => {
73
72
  });
74
73
 
75
74
  it("returns relative path from cwd", () => {
76
- mockedReadFileSync.mockReturnValue("content");
75
+ vi.mocked(mockFs.readFileSync).mockReturnValue("content");
77
76
 
78
- const result = resolveTemplate("plan.md", tokens, "/tmp/issues/42");
77
+ const result = resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
79
78
  // Should be a relative path (not starting with /)
80
79
  expect(result).not.toMatch(/^\/tmp/);
81
80
  });
82
81
 
83
82
  it("handles multiple occurrences of the same token", () => {
84
- mockedReadFileSync.mockReturnValue("{{MAIN_BRANCH}} and {{MAIN_BRANCH}} again");
83
+ vi.mocked(mockFs.readFileSync).mockReturnValue("{{MAIN_BRANCH}} and {{MAIN_BRANCH}} again");
85
84
 
86
- resolveTemplate("plan.md", tokens, "/tmp/issues/42");
85
+ resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
87
86
 
88
- const writtenContent = mockedWriteFileSync.mock.calls[0][1];
87
+ const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
89
88
  expect(writtenContent).toBe("main and main again");
90
89
  });
91
90
  });
@@ -1,4 +1,8 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import {
2
+ mkdirSync as defaultMkdirSync,
3
+ readFileSync as defaultReadFileSync,
4
+ writeFileSync as defaultWriteFileSync,
5
+ } from "node:fs";
2
6
  import { dirname, join, relative } from "node:path";
3
7
  import { fileURLToPath } from "node:url";
4
8
 
@@ -14,21 +18,34 @@ export interface TokenValues {
14
18
  REVIEW_FEEDBACK?: string;
15
19
  }
16
20
 
21
+ export interface TemplateFsDeps {
22
+ readFileSync: typeof defaultReadFileSync;
23
+ writeFileSync: typeof defaultWriteFileSync;
24
+ mkdirSync: typeof defaultMkdirSync;
25
+ }
26
+
27
+ const defaultFsDeps: TemplateFsDeps = {
28
+ readFileSync: defaultReadFileSync,
29
+ writeFileSync: defaultWriteFileSync,
30
+ mkdirSync: defaultMkdirSync,
31
+ };
32
+
17
33
  export function resolveTemplate(
18
34
  templateName: string,
19
35
  tokens: TokenValues,
20
36
  issueDir: string,
37
+ fs: TemplateFsDeps = defaultFsDeps,
21
38
  ): string {
22
39
  const templatePath = join(TEMPLATES_DIR, templateName);
23
- let template = readFileSync(templatePath, "utf-8");
40
+ let template = fs.readFileSync(templatePath, "utf-8") as string;
24
41
 
25
42
  for (const [key, value] of Object.entries(tokens)) {
26
43
  template = template.replaceAll(`{{${key}}}`, value);
27
44
  }
28
45
 
29
46
  const resolvedPath = join(issueDir, templateName);
30
- mkdirSync(dirname(resolvedPath), { recursive: true });
31
- writeFileSync(resolvedPath, template, "utf-8");
47
+ fs.mkdirSync(dirname(resolvedPath), { recursive: true });
48
+ fs.writeFileSync(resolvedPath, template, "utf-8");
32
49
 
33
50
  return relative(process.cwd(), resolvedPath);
34
51
  }
@@ -11,6 +11,7 @@ import { getConfig } from "./config.js";
11
11
  import { ARTIFACTS } from "./prompt-templates/index.js";
12
12
  import { resolveTemplate } from "./templates.js";
13
13
 
14
+ import type { SpawnClaudeFn } from "./spawn-claude.js";
14
15
  import type { TokenValues } from "./templates.js";
15
16
 
16
17
  export { ensureDir, fileExists, readFile, writeFile } from "../../utils/fs.js";
@@ -157,6 +158,7 @@ export interface StepRunnerOptions {
157
158
  artifactPath: string;
158
159
  templateName: string;
159
160
  artifactValidator?: (path: string) => boolean;
161
+ spawnFn?: SpawnClaudeFn;
160
162
  }
161
163
 
162
164
  /**
@@ -167,7 +169,7 @@ export interface StepRunnerOptions {
167
169
  * the same pattern.
168
170
  */
169
171
  export async function runStepWithArtifact(opts: StepRunnerOptions): Promise<boolean> {
170
- const { stepName, ctx, artifactPath, templateName, artifactValidator } = opts;
172
+ const { stepName, ctx, artifactPath, templateName, artifactValidator, spawnFn } = opts;
171
173
 
172
174
  const isValid = artifactValidator ?? fileExists;
173
175
  if (isValid(artifactPath)) {
@@ -183,6 +185,7 @@ export async function runStepWithArtifact(opts: StepRunnerOptions): Promise<bool
183
185
  const result = await runClaude({
184
186
  promptFile,
185
187
  maxTurns: getConfig().maxTurns,
188
+ spawnFn,
186
189
  });
187
190
 
188
191
  if (result.is_error) {
@@ -1,13 +1,9 @@
1
- import * as fs from "node:fs";
2
1
  import { describe, expect, it, vi } from "vitest";
3
2
 
3
+ import type { ReadFileFn } from "./parser";
4
4
  import { calculateCutoffMs, filterByDays, parseJsonl, quickTokenCount } from "./parser";
5
5
 
6
- vi.mock("node:fs", () => ({
7
- readFileSync: vi.fn(),
8
- }));
9
-
10
- const mockedReadFileSync = vi.mocked(fs.readFileSync);
6
+ const mockReadFileSync: ReadFileFn = vi.fn();
11
7
 
12
8
  // ── Pure functions (no mocking needed) ──
13
9
 
@@ -67,38 +63,38 @@ describe("filterByDays", () => {
67
63
  });
68
64
  });
69
65
 
70
- // ── parseJsonl and quickTokenCount (require fs mock) ──
66
+ // ── parseJsonl and quickTokenCount (use injected readFile) ──
71
67
 
72
68
  describe("parseJsonl", () => {
73
69
  it("parses valid JSONL lines", () => {
74
- mockedReadFileSync.mockReturnValue(
70
+ vi.mocked(mockReadFileSync).mockReturnValue(
75
71
  '{"type":"user","sessionId":"s1","timestamp":"2025-01-01T00:00:00Z"}\n{"type":"assistant","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}\n',
76
72
  );
77
- const entries = parseJsonl("/fake/path.jsonl");
73
+ const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
78
74
  expect(entries).toHaveLength(2);
79
75
  expect(entries[0].type).toBe("user");
80
76
  expect(entries[1].type).toBe("assistant");
81
77
  });
82
78
 
83
79
  it("skips empty lines", () => {
84
- mockedReadFileSync.mockReturnValue(
80
+ vi.mocked(mockReadFileSync).mockReturnValue(
85
81
  '{"type":"user","sessionId":"s1","timestamp":"t"}\n\n\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
86
82
  );
87
- const entries = parseJsonl("/fake/path.jsonl");
83
+ const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
88
84
  expect(entries).toHaveLength(2);
89
85
  });
90
86
 
91
87
  it("skips invalid JSON lines", () => {
92
- mockedReadFileSync.mockReturnValue(
88
+ vi.mocked(mockReadFileSync).mockReturnValue(
93
89
  '{"type":"user","sessionId":"s1","timestamp":"t"}\nnot-json\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
94
90
  );
95
- const entries = parseJsonl("/fake/path.jsonl");
91
+ const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
96
92
  expect(entries).toHaveLength(2);
97
93
  });
98
94
 
99
95
  it("returns empty array for empty file", () => {
100
- mockedReadFileSync.mockReturnValue("");
101
- const entries = parseJsonl("/fake/path.jsonl");
96
+ vi.mocked(mockReadFileSync).mockReturnValue("");
97
+ const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
102
98
  expect(entries).toHaveLength(0);
103
99
  });
104
100
  });
@@ -113,8 +109,8 @@ describe("quickTokenCount", () => {
113
109
  message: { usage: { input_tokens: 200, output_tokens: 75 } },
114
110
  }),
115
111
  ].join("\n");
116
- mockedReadFileSync.mockReturnValue(lines);
117
- expect(quickTokenCount("/fake/path.jsonl")).toBe(425);
112
+ vi.mocked(mockReadFileSync).mockReturnValue(lines);
113
+ expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(425);
118
114
  });
119
115
 
120
116
  it("skips entries without usage", () => {
@@ -122,29 +118,29 @@ describe("quickTokenCount", () => {
122
118
  JSON.stringify({ message: { content: "text" } }),
123
119
  JSON.stringify({ message: { usage: { input_tokens: 100, output_tokens: 50 } } }),
124
120
  ].join("\n");
125
- mockedReadFileSync.mockReturnValue(lines);
126
- expect(quickTokenCount("/fake/path.jsonl")).toBe(150);
121
+ vi.mocked(mockReadFileSync).mockReturnValue(lines);
122
+ expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(150);
127
123
  });
128
124
 
129
125
  it("returns 0 for unreadable files", () => {
130
- mockedReadFileSync.mockImplementation(() => {
126
+ vi.mocked(mockReadFileSync).mockImplementation(() => {
131
127
  throw new Error("ENOENT");
132
128
  });
133
- expect(quickTokenCount("/missing/file.jsonl")).toBe(0);
129
+ expect(quickTokenCount("/missing/file.jsonl", mockReadFileSync)).toBe(0);
134
130
  });
135
131
 
136
132
  it("handles entries with partial usage (only input_tokens)", () => {
137
133
  const lines = JSON.stringify({
138
134
  message: { usage: { input_tokens: 100 } },
139
135
  });
140
- mockedReadFileSync.mockReturnValue(lines);
141
- expect(quickTokenCount("/fake/path.jsonl")).toBe(100);
136
+ vi.mocked(mockReadFileSync).mockReturnValue(lines);
137
+ expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(100);
142
138
  });
143
139
 
144
140
  it("skips invalid JSON lines gracefully", () => {
145
- mockedReadFileSync.mockReturnValue(
141
+ vi.mocked(mockReadFileSync).mockReturnValue(
146
142
  '{"message":{"usage":{"input_tokens":50,"output_tokens":50}}}\nbadline\n',
147
143
  );
148
- expect(quickTokenCount("/fake/path.jsonl")).toBe(100);
144
+ expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(100);
149
145
  });
150
146
  });
@@ -1,6 +1,9 @@
1
- import * as fs from "node:fs";
1
+ import { readFileSync as defaultReadFileSync } from "node:fs";
2
+
2
3
  import type { JournalEntry } from "./types.js";
3
4
 
5
+ export type ReadFileFn = (path: string, encoding: BufferEncoding) => string;
6
+
4
7
  /**
5
8
  * Calculate cutoff timestamp for days filtering.
6
9
  * Returns 0 if days <= 0 (no filtering).
@@ -22,8 +25,11 @@ export function filterByDays<T extends { mtime: number }>(items: T[], days: numb
22
25
  /**
23
26
  * Parse JSONL file into JournalEntry array.
24
27
  */
25
- export function parseJsonl(filePath: string): JournalEntry[] {
26
- const content = fs.readFileSync(filePath, "utf-8");
28
+ export function parseJsonl(
29
+ filePath: string,
30
+ readFile: ReadFileFn = defaultReadFileSync,
31
+ ): JournalEntry[] {
32
+ const content = readFile(filePath, "utf-8");
27
33
  const entries: JournalEntry[] = [];
28
34
 
29
35
  for (const line of content.split("\n")) {
@@ -41,9 +47,12 @@ export function parseJsonl(filePath: string): JournalEntry[] {
41
47
  /**
42
48
  * Quick token count from a JSONL file without full parsing.
43
49
  */
44
- export function quickTokenCount(filePath: string): number {
50
+ export function quickTokenCount(
51
+ filePath: string,
52
+ readFile: ReadFileFn = defaultReadFileSync,
53
+ ): number {
45
54
  try {
46
- const content = fs.readFileSync(filePath, "utf-8");
55
+ const content = readFile(filePath, "utf-8");
47
56
  let total = 0;
48
57
  for (const line of content.split("\n")) {
49
58
  if (!line.trim()) continue;
@@ -1,35 +1,32 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { x } from "tinyexec";
3
- import { getIssues, isGithubCliInstalled } from "./gh-cli-wrapper";
4
2
 
5
- vi.mock("tinyexec", () => ({
6
- x: vi.fn().mockResolvedValue({ stdout: "[]" }),
7
- }));
3
+ import type { XFn } from "./gh-cli-wrapper";
4
+ import { getIssues, isGithubCliInstalled } from "./gh-cli-wrapper";
8
5
 
9
- const mockX = vi.mocked(x);
6
+ const mockX: XFn = vi.fn().mockResolvedValue({ stdout: "[]", stderr: "", exitCode: 0 });
10
7
 
11
8
  describe("gh-cli-wrapper", () => {
12
9
  beforeEach(() => {
13
10
  vi.clearAllMocks();
14
- mockX.mockResolvedValue({ stdout: "[]" } as never);
11
+ vi.mocked(mockX).mockResolvedValue({ stdout: "[]", stderr: "", exitCode: 0 });
15
12
  });
16
13
 
17
14
  describe("getIssues", () => {
18
15
  it("passes --label flag when label provided", async () => {
19
- await getIssues({ cwd: ".", label: "auto-claude" });
16
+ await getIssues({ cwd: ".", label: "auto-claude", exec: mockX });
20
17
 
21
18
  expect(mockX).toHaveBeenCalledWith("gh", expect.arrayContaining(["--label", "auto-claude"]));
22
19
  });
23
20
 
24
21
  it("does not pass --label flag when label not provided", async () => {
25
- await getIssues({ cwd: "." });
22
+ await getIssues({ cwd: ".", exec: mockX });
26
23
 
27
- const args = mockX.mock.calls[0]![1] as string[];
24
+ const args = vi.mocked(mockX).mock.calls[0]![1] as string[];
28
25
  expect(args).not.toContain("--label");
29
26
  });
30
27
 
31
28
  it("passes --assignee @me flag when assignedToMe is true", async () => {
32
- await getIssues({ cwd: ".", assignedToMe: true });
29
+ await getIssues({ cwd: ".", assignedToMe: true, exec: mockX });
33
30
 
34
31
  expect(mockX).toHaveBeenCalledWith("gh", expect.arrayContaining(["--assignee", "@me"]));
35
32
  });
@@ -37,16 +34,20 @@ describe("gh-cli-wrapper", () => {
37
34
 
38
35
  describe("isGithubCliInstalled", () => {
39
36
  it("returns true when gh CLI outputs expected string", async () => {
40
- mockX.mockResolvedValue({ stdout: "gh version 2.0.0 (https://github.com/cli/cli)" } as never);
37
+ vi.mocked(mockX).mockResolvedValue({
38
+ stdout: "gh version 2.0.0 (https://github.com/cli/cli)",
39
+ stderr: "",
40
+ exitCode: 0,
41
+ });
41
42
 
42
- const result = await isGithubCliInstalled();
43
+ const result = await isGithubCliInstalled(mockX);
43
44
  expect(result).toBe(true);
44
45
  });
45
46
 
46
47
  it("returns false when gh CLI is not available", async () => {
47
- mockX.mockRejectedValue(new Error("command not found"));
48
+ vi.mocked(mockX).mockRejectedValue(new Error("command not found"));
48
49
 
49
- const result = await isGithubCliInstalled();
50
+ const result = await isGithubCliInstalled(mockX);
50
51
  expect(result).toBe(false);
51
52
  });
52
53
  });
@@ -1,11 +1,18 @@
1
1
  import stripAnsi from "strip-ansi";
2
2
  import { x } from "tinyexec";
3
+ import type { Output } from "tinyexec";
3
4
 
4
5
  import { execSafe } from "./exec.js";
5
6
 
6
- export async function isGithubCliInstalled(): Promise<boolean> {
7
+ export type XFn = (
8
+ cmd: string,
9
+ args?: string[],
10
+ opts?: Record<string, unknown>,
11
+ ) => PromiseLike<Output>;
12
+
13
+ export async function isGithubCliInstalled(exec: XFn = x as XFn): Promise<boolean> {
7
14
  try {
8
- const proc = await x("gh", ["--version"]);
15
+ const proc = await exec("gh", ["--version"]);
9
16
  return proc.stdout.includes("https://github.com/cli/cli");
10
17
  } catch {
11
18
  // gh CLI not installed or not accessible
@@ -18,8 +25,12 @@ export async function gh<T = unknown>(args: string[]): Promise<T> {
18
25
  return JSON.parse(result.stdout.trim()) as T;
19
26
  }
20
27
 
21
- export async function ghRaw(args: string[]): Promise<string> {
22
- const result = await execSafe("gh", args);
28
+ export async function ghRaw(
29
+ args: string[],
30
+ exec?: (cmd: string, args: string[]) => Promise<{ stdout: string; ok: boolean }>,
31
+ ): Promise<string> {
32
+ const execFn = exec ?? execSafe;
33
+ const result = await execFn("gh", args);
23
34
  return result.stdout;
24
35
  }
25
36
 
@@ -37,9 +48,11 @@ export async function getIssues({
37
48
  assignedToMe,
38
49
  cwd,
39
50
  label,
51
+ exec = x as XFn,
40
52
  }: {
41
53
  assignedToMe?: boolean;
42
54
  cwd: string;
55
+ exec?: XFn;
43
56
  label?: string;
44
57
  }): Promise<Issue[]> {
45
58
  const args = ["issue", "list", "--json", "labels,number,title,state"];
@@ -52,7 +65,7 @@ export async function getIssues({
52
65
  args.push("--label", label);
53
66
  }
54
67
 
55
- const result = await x("gh", args);
68
+ const result = await exec("gh", args);
56
69
  // Setting NO_COLOR=1 didn't remove colors so had to use stripAnsi
57
70
  const stripped = stripAnsi(result.stdout);
58
71