@markjaquith/agency 1.6.3 → 1.7.0

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.
@@ -0,0 +1,432 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { Effect } from "effect"
4
+ import { FormatterService } from "../services/FormatterService"
5
+ import { createTempDir, cleanupTempDir, runTestEffect } from "../test-utils"
6
+
7
+ describe("FormatterService", () => {
8
+ let tempDir: string
9
+
10
+ beforeEach(async () => {
11
+ tempDir = await createTempDir()
12
+ })
13
+
14
+ afterEach(async () => {
15
+ await cleanupTempDir(tempDir)
16
+ })
17
+
18
+ describe("detectPackageManager", () => {
19
+ test("returns null when no package.json exists", async () => {
20
+ const result = await runTestEffect(
21
+ Effect.gen(function* () {
22
+ const service = yield* FormatterService
23
+ return yield* service.detectPackageManager(tempDir)
24
+ }),
25
+ )
26
+ expect(result).toBeNull()
27
+ })
28
+
29
+ test("detects bun from bun.lockb", async () => {
30
+ await Bun.write(join(tempDir, "package.json"), "{}")
31
+ await Bun.write(join(tempDir, "bun.lockb"), "")
32
+
33
+ const result = await runTestEffect(
34
+ Effect.gen(function* () {
35
+ const service = yield* FormatterService
36
+ return yield* service.detectPackageManager(tempDir)
37
+ }),
38
+ )
39
+ expect(result).toBe("bun")
40
+ })
41
+
42
+ test("detects bun from bun.lock", async () => {
43
+ await Bun.write(join(tempDir, "package.json"), "{}")
44
+ await Bun.write(join(tempDir, "bun.lock"), "")
45
+
46
+ const result = await runTestEffect(
47
+ Effect.gen(function* () {
48
+ const service = yield* FormatterService
49
+ return yield* service.detectPackageManager(tempDir)
50
+ }),
51
+ )
52
+ expect(result).toBe("bun")
53
+ })
54
+
55
+ test("detects yarn from yarn.lock", async () => {
56
+ await Bun.write(join(tempDir, "package.json"), "{}")
57
+ await Bun.write(join(tempDir, "yarn.lock"), "")
58
+
59
+ const result = await runTestEffect(
60
+ Effect.gen(function* () {
61
+ const service = yield* FormatterService
62
+ return yield* service.detectPackageManager(tempDir)
63
+ }),
64
+ )
65
+ expect(result).toBe("yarn")
66
+ })
67
+
68
+ test("detects pnpm from pnpm-lock.yaml", async () => {
69
+ await Bun.write(join(tempDir, "package.json"), "{}")
70
+ await Bun.write(join(tempDir, "pnpm-lock.yaml"), "")
71
+
72
+ const result = await runTestEffect(
73
+ Effect.gen(function* () {
74
+ const service = yield* FormatterService
75
+ return yield* service.detectPackageManager(tempDir)
76
+ }),
77
+ )
78
+ expect(result).toBe("pnpm")
79
+ })
80
+
81
+ test("detects npm from package-lock.json", async () => {
82
+ await Bun.write(join(tempDir, "package.json"), "{}")
83
+ await Bun.write(join(tempDir, "package-lock.json"), "{}")
84
+
85
+ const result = await runTestEffect(
86
+ Effect.gen(function* () {
87
+ const service = yield* FormatterService
88
+ return yield* service.detectPackageManager(tempDir)
89
+ }),
90
+ )
91
+ expect(result).toBe("npm")
92
+ })
93
+
94
+ test("defaults to npm when package.json exists but no lock file", async () => {
95
+ await Bun.write(join(tempDir, "package.json"), "{}")
96
+
97
+ const result = await runTestEffect(
98
+ Effect.gen(function* () {
99
+ const service = yield* FormatterService
100
+ return yield* service.detectPackageManager(tempDir)
101
+ }),
102
+ )
103
+ expect(result).toBe("npm")
104
+ })
105
+
106
+ test("bun.lockb takes priority over yarn.lock", async () => {
107
+ await Bun.write(join(tempDir, "package.json"), "{}")
108
+ await Bun.write(join(tempDir, "bun.lockb"), "")
109
+ await Bun.write(join(tempDir, "yarn.lock"), "")
110
+
111
+ const result = await runTestEffect(
112
+ Effect.gen(function* () {
113
+ const service = yield* FormatterService
114
+ return yield* service.detectPackageManager(tempDir)
115
+ }),
116
+ )
117
+ expect(result).toBe("bun")
118
+ })
119
+ })
120
+
121
+ describe("detectFormatter", () => {
122
+ test("returns null when no package.json exists", async () => {
123
+ const result = await runTestEffect(
124
+ Effect.gen(function* () {
125
+ const service = yield* FormatterService
126
+ return yield* service.detectFormatter(tempDir)
127
+ }),
128
+ )
129
+ expect(result).toBeNull()
130
+ })
131
+
132
+ test("detects prettier in devDependencies", async () => {
133
+ await Bun.write(
134
+ join(tempDir, "package.json"),
135
+ JSON.stringify({
136
+ devDependencies: { prettier: "^3.0.0" },
137
+ }),
138
+ )
139
+
140
+ const result = await runTestEffect(
141
+ Effect.gen(function* () {
142
+ const service = yield* FormatterService
143
+ return yield* service.detectFormatter(tempDir)
144
+ }),
145
+ )
146
+ expect(result).toBe("prettier")
147
+ })
148
+
149
+ test("detects prettier in dependencies", async () => {
150
+ await Bun.write(
151
+ join(tempDir, "package.json"),
152
+ JSON.stringify({
153
+ dependencies: { prettier: "^3.0.0" },
154
+ }),
155
+ )
156
+
157
+ const result = await runTestEffect(
158
+ Effect.gen(function* () {
159
+ const service = yield* FormatterService
160
+ return yield* service.detectFormatter(tempDir)
161
+ }),
162
+ )
163
+ expect(result).toBe("prettier")
164
+ })
165
+
166
+ test("detects oxfmt in devDependencies", async () => {
167
+ await Bun.write(
168
+ join(tempDir, "package.json"),
169
+ JSON.stringify({
170
+ devDependencies: { oxfmt: "^0.1.0" },
171
+ }),
172
+ )
173
+
174
+ const result = await runTestEffect(
175
+ Effect.gen(function* () {
176
+ const service = yield* FormatterService
177
+ return yield* service.detectFormatter(tempDir)
178
+ }),
179
+ )
180
+ expect(result).toBe("oxfmt")
181
+ })
182
+
183
+ test("oxfmt takes priority over prettier", async () => {
184
+ await Bun.write(
185
+ join(tempDir, "package.json"),
186
+ JSON.stringify({
187
+ devDependencies: { prettier: "^3.0.0", oxfmt: "^0.1.0" },
188
+ }),
189
+ )
190
+
191
+ const result = await runTestEffect(
192
+ Effect.gen(function* () {
193
+ const service = yield* FormatterService
194
+ return yield* service.detectFormatter(tempDir)
195
+ }),
196
+ )
197
+ expect(result).toBe("oxfmt")
198
+ })
199
+
200
+ test("returns null when no formatter is in dependencies", async () => {
201
+ await Bun.write(
202
+ join(tempDir, "package.json"),
203
+ JSON.stringify({
204
+ devDependencies: { typescript: "^5.0.0" },
205
+ }),
206
+ )
207
+
208
+ const result = await runTestEffect(
209
+ Effect.gen(function* () {
210
+ const service = yield* FormatterService
211
+ return yield* service.detectFormatter(tempDir)
212
+ }),
213
+ )
214
+ expect(result).toBeNull()
215
+ })
216
+
217
+ test("handles malformed package.json gracefully", async () => {
218
+ await Bun.write(join(tempDir, "package.json"), "not valid json")
219
+
220
+ const result = await runTestEffect(
221
+ Effect.gen(function* () {
222
+ const service = yield* FormatterService
223
+ return yield* service.detectFormatter(tempDir)
224
+ }),
225
+ )
226
+ expect(result).toBeNull()
227
+ })
228
+ })
229
+
230
+ describe("buildFormatterCommand", () => {
231
+ test("builds prettier command with bun", async () => {
232
+ const result = await runTestEffect(
233
+ Effect.gen(function* () {
234
+ const service = yield* FormatterService
235
+ return yield* service.buildFormatterCommand("prettier", "bun", [
236
+ "/path/to/file.md",
237
+ ])
238
+ }),
239
+ )
240
+ expect(result).toEqual([
241
+ "bun",
242
+ "x",
243
+ "prettier",
244
+ "--write",
245
+ "/path/to/file.md",
246
+ ])
247
+ })
248
+
249
+ test("builds prettier command with yarn", async () => {
250
+ const result = await runTestEffect(
251
+ Effect.gen(function* () {
252
+ const service = yield* FormatterService
253
+ return yield* service.buildFormatterCommand("prettier", "yarn", [
254
+ "/path/to/file.md",
255
+ ])
256
+ }),
257
+ )
258
+ expect(result).toEqual([
259
+ "yarn",
260
+ "dlx",
261
+ "prettier",
262
+ "--write",
263
+ "/path/to/file.md",
264
+ ])
265
+ })
266
+
267
+ test("builds prettier command with pnpm", async () => {
268
+ const result = await runTestEffect(
269
+ Effect.gen(function* () {
270
+ const service = yield* FormatterService
271
+ return yield* service.buildFormatterCommand("prettier", "pnpm", [
272
+ "/path/to/file.md",
273
+ ])
274
+ }),
275
+ )
276
+ expect(result).toEqual([
277
+ "pnpm",
278
+ "exec",
279
+ "prettier",
280
+ "--write",
281
+ "/path/to/file.md",
282
+ ])
283
+ })
284
+
285
+ test("builds prettier command with npm", async () => {
286
+ const result = await runTestEffect(
287
+ Effect.gen(function* () {
288
+ const service = yield* FormatterService
289
+ return yield* service.buildFormatterCommand("prettier", "npm", [
290
+ "/path/to/file.md",
291
+ ])
292
+ }),
293
+ )
294
+ expect(result).toEqual(["npx", "prettier", "--write", "/path/to/file.md"])
295
+ })
296
+
297
+ test("builds oxfmt command with bun", async () => {
298
+ const result = await runTestEffect(
299
+ Effect.gen(function* () {
300
+ const service = yield* FormatterService
301
+ return yield* service.buildFormatterCommand("oxfmt", "bun", [
302
+ "/path/to/file.json",
303
+ ])
304
+ }),
305
+ )
306
+ expect(result).toEqual(["bun", "x", "oxfmt", "/path/to/file.json"])
307
+ })
308
+
309
+ test("handles multiple files", async () => {
310
+ const result = await runTestEffect(
311
+ Effect.gen(function* () {
312
+ const service = yield* FormatterService
313
+ return yield* service.buildFormatterCommand("prettier", "bun", [
314
+ "/path/to/file.md",
315
+ "/path/to/file.json",
316
+ ])
317
+ }),
318
+ )
319
+ expect(result).toEqual([
320
+ "bun",
321
+ "x",
322
+ "prettier",
323
+ "--write",
324
+ "/path/to/file.md",
325
+ "/path/to/file.json",
326
+ ])
327
+ })
328
+
329
+ test("returns null for empty file list", async () => {
330
+ const result = await runTestEffect(
331
+ Effect.gen(function* () {
332
+ const service = yield* FormatterService
333
+ return yield* service.buildFormatterCommand("prettier", "bun", [])
334
+ }),
335
+ )
336
+ expect(result).toBeNull()
337
+ })
338
+ })
339
+
340
+ describe("formatFiles", () => {
341
+ test("silently does nothing when no package.json exists", async () => {
342
+ const logs: string[] = []
343
+ const verboseLog = (msg: string) => logs.push(msg)
344
+
345
+ await runTestEffect(
346
+ Effect.gen(function* () {
347
+ const service = yield* FormatterService
348
+ yield* service.formatFiles(tempDir, ["AGENTS.md"], verboseLog)
349
+ }),
350
+ )
351
+
352
+ expect(logs.some((l) => l.includes("No formatter"))).toBe(true)
353
+ })
354
+
355
+ test("silently does nothing when no formatter in package.json", async () => {
356
+ await Bun.write(
357
+ join(tempDir, "package.json"),
358
+ JSON.stringify({ devDependencies: { typescript: "^5.0.0" } }),
359
+ )
360
+
361
+ const logs: string[] = []
362
+ const verboseLog = (msg: string) => logs.push(msg)
363
+
364
+ await runTestEffect(
365
+ Effect.gen(function* () {
366
+ const service = yield* FormatterService
367
+ yield* service.formatFiles(tempDir, ["AGENTS.md"], verboseLog)
368
+ }),
369
+ )
370
+
371
+ expect(logs.some((l) => l.includes("No formatter"))).toBe(true)
372
+ })
373
+
374
+ test("filters to only md/json/jsonc files", async () => {
375
+ const logs: string[] = []
376
+ const verboseLog = (msg: string) => logs.push(msg)
377
+
378
+ await runTestEffect(
379
+ Effect.gen(function* () {
380
+ const service = yield* FormatterService
381
+ yield* service.formatFiles(
382
+ tempDir,
383
+ ["somefile.ts", "other.txt"],
384
+ verboseLog,
385
+ )
386
+ }),
387
+ )
388
+
389
+ expect(logs.some((l) => l.includes("No formattable files"))).toBe(true)
390
+ })
391
+
392
+ test("does nothing with empty file list", async () => {
393
+ const logs: string[] = []
394
+ const verboseLog = (msg: string) => logs.push(msg)
395
+
396
+ await runTestEffect(
397
+ Effect.gen(function* () {
398
+ const service = yield* FormatterService
399
+ yield* service.formatFiles(tempDir, [], verboseLog)
400
+ }),
401
+ )
402
+
403
+ expect(logs.some((l) => l.includes("No files to format"))).toBe(true)
404
+ })
405
+
406
+ test("silently handles formatter command failure", async () => {
407
+ // Set up a project with prettier but no actual prettier installed
408
+ await Bun.write(
409
+ join(tempDir, "package.json"),
410
+ JSON.stringify({ devDependencies: { prettier: "^3.0.0" } }),
411
+ )
412
+ await Bun.write(join(tempDir, "bun.lockb"), "")
413
+ await Bun.write(join(tempDir, "AGENTS.md"), "# Test")
414
+
415
+ const logs: string[] = []
416
+ const verboseLog = (msg: string) => logs.push(msg)
417
+
418
+ // Should not throw even though prettier isn't actually installed
419
+ await runTestEffect(
420
+ Effect.gen(function* () {
421
+ const service = yield* FormatterService
422
+ yield* service.formatFiles(tempDir, ["AGENTS.md"], verboseLog)
423
+ }),
424
+ )
425
+
426
+ // Should have detected the formatter and attempted to run it
427
+ expect(logs.some((l) => l.includes("Detected formatter: prettier"))).toBe(
428
+ true,
429
+ )
430
+ })
431
+ })
432
+ })
@@ -0,0 +1,219 @@
1
+ import { Effect } from "effect"
2
+ import { resolve } from "path"
3
+ import { FileSystemService } from "./FileSystemService"
4
+
5
+ type PackageJson = {
6
+ dependencies?: Record<string, string>
7
+ devDependencies?: Record<string, string>
8
+ }
9
+
10
+ type PackageManager = "bun" | "yarn" | "pnpm" | "npm"
11
+
12
+ type Formatter = "prettier" | "oxfmt"
13
+
14
+ /**
15
+ * Service for detecting and running code formatters on agency-created files.
16
+ *
17
+ * When agency creates files (markdown, json), pre-commit or pre-push hooks
18
+ * in the target project may fail if those files aren't formatted according
19
+ * to the project's formatter settings. This service detects the project's
20
+ * formatter (prettier or oxfmt) and package manager, then runs the formatter
21
+ * on the specified files. Failures are silently ignored.
22
+ */
23
+ export class FormatterService extends Effect.Service<FormatterService>()(
24
+ "FormatterService",
25
+ {
26
+ sync: () => ({
27
+ /**
28
+ * Detect which package manager the project uses by checking for lock files.
29
+ * Falls back to npm if no lock file is found but package.json exists.
30
+ * Returns null if no package.json exists.
31
+ */
32
+ detectPackageManager: (gitRoot: string) =>
33
+ Effect.gen(function* () {
34
+ const fs = yield* FileSystemService
35
+
36
+ // Check for package.json first - if it doesn't exist, no package manager
37
+ const hasPackageJson = yield* fs.exists(
38
+ resolve(gitRoot, "package.json"),
39
+ )
40
+ if (!hasPackageJson) {
41
+ return null
42
+ }
43
+
44
+ // Check lock files in priority order
45
+ if (yield* fs.exists(resolve(gitRoot, "bun.lockb")))
46
+ return "bun" as PackageManager
47
+ if (yield* fs.exists(resolve(gitRoot, "bun.lock")))
48
+ return "bun" as PackageManager
49
+ if (yield* fs.exists(resolve(gitRoot, "yarn.lock")))
50
+ return "yarn" as PackageManager
51
+ if (yield* fs.exists(resolve(gitRoot, "pnpm-lock.yaml")))
52
+ return "pnpm" as PackageManager
53
+ if (yield* fs.exists(resolve(gitRoot, "package-lock.json")))
54
+ return "npm" as PackageManager
55
+
56
+ // Default to npm if package.json exists but no lock file found
57
+ return "npm" as PackageManager
58
+ }),
59
+
60
+ /**
61
+ * Detect which formatter is available in the project's package.json.
62
+ * Checks both dependencies and devDependencies for prettier and oxfmt.
63
+ * Returns null if neither is found.
64
+ */
65
+ detectFormatter: (gitRoot: string) =>
66
+ Effect.gen(function* () {
67
+ const fs = yield* FileSystemService
68
+
69
+ const packageJsonPath = resolve(gitRoot, "package.json")
70
+ const exists = yield* fs.exists(packageJsonPath)
71
+ if (!exists) {
72
+ return null
73
+ }
74
+
75
+ const packageJson = yield* fs.readJSON<PackageJson>(packageJsonPath)
76
+
77
+ const allDeps = {
78
+ ...packageJson.dependencies,
79
+ ...packageJson.devDependencies,
80
+ }
81
+
82
+ // Check for oxfmt first (newer/faster), then prettier
83
+ if ("oxfmt" in allDeps) return "oxfmt" as Formatter
84
+ if ("prettier" in allDeps) return "prettier" as Formatter
85
+
86
+ return null
87
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))),
88
+
89
+ /**
90
+ * Build the command to run a formatter via the detected package manager.
91
+ * Returns the command args array, or null if the formatter/pm combo isn't supported.
92
+ */
93
+ buildFormatterCommand: (
94
+ formatter: Formatter,
95
+ packageManager: PackageManager,
96
+ files: string[],
97
+ ) =>
98
+ Effect.sync(() => {
99
+ if (files.length === 0) return null
100
+
101
+ // Build the runner prefix based on package manager
102
+ const runnerPrefix: string[] = (() => {
103
+ switch (packageManager) {
104
+ case "bun":
105
+ return ["bun", "x"]
106
+ case "yarn":
107
+ return ["yarn", "dlx"]
108
+ case "pnpm":
109
+ return ["pnpm", "exec"]
110
+ case "npm":
111
+ return ["npx"]
112
+ }
113
+ })()
114
+
115
+ // Build the formatter command
116
+ switch (formatter) {
117
+ case "prettier":
118
+ return [...runnerPrefix, "prettier", "--write", ...files]
119
+ case "oxfmt":
120
+ return [...runnerPrefix, "oxfmt", ...files]
121
+ default:
122
+ return null
123
+ }
124
+ }),
125
+
126
+ /**
127
+ * Format the given files using the project's detected formatter.
128
+ * Silently returns if no formatter is detected or if the command fails.
129
+ * This is the main entry point for formatting agency-created files.
130
+ */
131
+ formatFiles: (
132
+ gitRoot: string,
133
+ files: string[],
134
+ verboseLog: (msg: string) => void,
135
+ ) =>
136
+ Effect.gen(function* () {
137
+ const fs = yield* FileSystemService
138
+
139
+ if (files.length === 0) {
140
+ verboseLog("No files to format")
141
+ return
142
+ }
143
+
144
+ // Filter to only .md and .json/.jsonc files (the types agency creates)
145
+ const formattableFiles = files.filter(
146
+ (f) =>
147
+ f.endsWith(".md") || f.endsWith(".json") || f.endsWith(".jsonc"),
148
+ )
149
+
150
+ if (formattableFiles.length === 0) {
151
+ verboseLog("No formattable files (md/json/jsonc) found")
152
+ return
153
+ }
154
+
155
+ // Use yield* on the service methods via self-reference through the service tag
156
+ const formatterService = yield* FormatterService
157
+
158
+ const formatter = yield* formatterService.detectFormatter(gitRoot)
159
+ if (!formatter) {
160
+ verboseLog("No formatter (prettier/oxfmt) detected in package.json")
161
+ return
162
+ }
163
+
164
+ const packageManager =
165
+ yield* formatterService.detectPackageManager(gitRoot)
166
+ if (!packageManager) {
167
+ verboseLog("No package manager detected")
168
+ return
169
+ }
170
+
171
+ verboseLog(
172
+ `Detected formatter: ${formatter}, package manager: ${packageManager}`,
173
+ )
174
+
175
+ // Build absolute paths for the files
176
+ const absolutePaths = formattableFiles.map((f) => resolve(gitRoot, f))
177
+
178
+ const command = yield* formatterService.buildFormatterCommand(
179
+ formatter,
180
+ packageManager,
181
+ absolutePaths,
182
+ )
183
+ if (!command) {
184
+ verboseLog("Could not build formatter command")
185
+ return
186
+ }
187
+
188
+ verboseLog(`Running formatter: ${command.join(" ")}`)
189
+
190
+ // Run the formatter, silently ignoring failures
191
+ yield* fs
192
+ .runCommand(command, {
193
+ cwd: gitRoot,
194
+ captureOutput: true,
195
+ })
196
+ .pipe(
197
+ Effect.catchAll((err) => {
198
+ verboseLog(`Formatter failed (silently ignoring): ${err}`)
199
+ return Effect.void
200
+ }),
201
+ )
202
+
203
+ verboseLog(
204
+ `Formatted ${formattableFiles.length} file(s) with ${formatter}`,
205
+ )
206
+ }).pipe(
207
+ // Catch any unexpected errors silently
208
+ Effect.catchAll((err) => {
209
+ verboseLog(`Formatting failed (silently ignoring): ${err}`)
210
+ return Effect.void
211
+ }),
212
+ Effect.catchAllDefect((defect) => {
213
+ verboseLog(`Formatting defect (silently ignoring): ${defect}`)
214
+ return Effect.void
215
+ }),
216
+ ),
217
+ }),
218
+ },
219
+ ) {}
@@ -5,6 +5,7 @@ import {
5
5
  getTemplateDir,
6
6
  getTemplatesDir,
7
7
  } from "../utils/paths"
8
+ import { FileSystemService } from "./FileSystemService"
8
9
 
9
10
  // Error types for Template operations
10
11
  class TemplateError extends Data.TaggedError("TemplateError")<{
@@ -35,9 +36,14 @@ export class TemplateService extends Effect.Service<TemplateService>()(
35
36
  }),
36
37
  }),
37
38
 
38
- createTemplateDir: (_templateName: string) =>
39
- Effect.sync(() => {
40
- // Directory creation is handled by FileSystemService.createDirectory
39
+ createTemplateDir: (templateName: string) =>
40
+ Effect.gen(function* () {
41
+ const fs = yield* FileSystemService
42
+ const templateDir = getTemplateDir(templateName)
43
+ const exists = yield* fs.exists(templateDir)
44
+ if (!exists) {
45
+ yield* fs.createDirectory(templateDir)
46
+ }
41
47
  }),
42
48
 
43
49
  listTemplates: () =>
package/src/test-utils.ts CHANGED
@@ -383,6 +383,7 @@ import { OpencodeService } from "./services/OpencodeService"
383
383
  import { ClaudeService } from "./services/ClaudeService"
384
384
  import { FilterRepoService } from "./services/FilterRepoService"
385
385
  import { MockFilterRepoService } from "./services/MockFilterRepoService"
386
+ import { FormatterService } from "./services/FormatterService"
386
387
 
387
388
  // Re-export mock utilities for tests
388
389
  export {
@@ -402,6 +403,7 @@ const TestLayer = Layer.mergeAll(
402
403
  OpencodeService.Default,
403
404
  ClaudeService.Default,
404
405
  FilterRepoService.Default,
406
+ FormatterService.Default,
405
407
  )
406
408
 
407
409
  // Create test layer with mock filter-repo (for tests that don't need real filtering)
@@ -414,6 +416,7 @@ const TestLayerWithMockFilterRepo = Layer.mergeAll(
414
416
  OpencodeService.Default,
415
417
  ClaudeService.Default,
416
418
  MockFilterRepoService.Default,
419
+ FormatterService.Default,
417
420
  )
418
421
 
419
422
  export async function runTestEffect<A, E>(