@markjaquith/agency 0.5.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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/cli.ts +569 -0
  4. package/index.ts +1 -0
  5. package/package.json +65 -0
  6. package/src/commands/base.test.ts +198 -0
  7. package/src/commands/base.ts +198 -0
  8. package/src/commands/clean.test.ts +299 -0
  9. package/src/commands/clean.ts +320 -0
  10. package/src/commands/emit.test.ts +412 -0
  11. package/src/commands/emit.ts +521 -0
  12. package/src/commands/emitted.test.ts +226 -0
  13. package/src/commands/emitted.ts +57 -0
  14. package/src/commands/init.test.ts +311 -0
  15. package/src/commands/init.ts +140 -0
  16. package/src/commands/merge.test.ts +365 -0
  17. package/src/commands/merge.ts +253 -0
  18. package/src/commands/pull.test.ts +385 -0
  19. package/src/commands/pull.ts +205 -0
  20. package/src/commands/push.test.ts +394 -0
  21. package/src/commands/push.ts +346 -0
  22. package/src/commands/save.test.ts +247 -0
  23. package/src/commands/save.ts +162 -0
  24. package/src/commands/source.test.ts +195 -0
  25. package/src/commands/source.ts +72 -0
  26. package/src/commands/status.test.ts +489 -0
  27. package/src/commands/status.ts +258 -0
  28. package/src/commands/switch.test.ts +194 -0
  29. package/src/commands/switch.ts +84 -0
  30. package/src/commands/task-branching.test.ts +334 -0
  31. package/src/commands/task-edit.test.ts +141 -0
  32. package/src/commands/task-main.test.ts +872 -0
  33. package/src/commands/task.ts +712 -0
  34. package/src/commands/tasks.test.ts +335 -0
  35. package/src/commands/tasks.ts +155 -0
  36. package/src/commands/template-delete.test.ts +178 -0
  37. package/src/commands/template-delete.ts +98 -0
  38. package/src/commands/template-list.test.ts +135 -0
  39. package/src/commands/template-list.ts +87 -0
  40. package/src/commands/template-view.test.ts +158 -0
  41. package/src/commands/template-view.ts +86 -0
  42. package/src/commands/template.test.ts +32 -0
  43. package/src/commands/template.ts +96 -0
  44. package/src/commands/use.test.ts +87 -0
  45. package/src/commands/use.ts +97 -0
  46. package/src/commands/work.test.ts +462 -0
  47. package/src/commands/work.ts +193 -0
  48. package/src/errors.ts +17 -0
  49. package/src/schemas.ts +33 -0
  50. package/src/services/AgencyMetadataService.ts +287 -0
  51. package/src/services/ClaudeService.test.ts +184 -0
  52. package/src/services/ClaudeService.ts +91 -0
  53. package/src/services/ConfigService.ts +115 -0
  54. package/src/services/FileSystemService.ts +222 -0
  55. package/src/services/GitService.ts +751 -0
  56. package/src/services/OpencodeService.ts +263 -0
  57. package/src/services/PromptService.ts +183 -0
  58. package/src/services/TemplateService.ts +75 -0
  59. package/src/test-utils.ts +362 -0
  60. package/src/types/native-exec.d.ts +8 -0
  61. package/src/types.ts +216 -0
  62. package/src/utils/colors.ts +178 -0
  63. package/src/utils/command.ts +17 -0
  64. package/src/utils/effect.ts +281 -0
  65. package/src/utils/exec.ts +48 -0
  66. package/src/utils/paths.ts +51 -0
  67. package/src/utils/pr-branch.test.ts +372 -0
  68. package/src/utils/pr-branch.ts +473 -0
  69. package/src/utils/process.ts +110 -0
  70. package/src/utils/spinner.ts +82 -0
  71. package/templates/AGENCY.md +20 -0
  72. package/templates/AGENTS.md +11 -0
  73. package/templates/CLAUDE.md +3 -0
  74. package/templates/TASK.md +5 -0
  75. package/templates/opencode.json +4 -0
@@ -0,0 +1,872 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { task } from "../commands/task"
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ initGitRepo,
8
+ initAgency,
9
+ createSubdir,
10
+ fileExists,
11
+ readFile,
12
+ runTestEffect,
13
+ } from "../test-utils"
14
+
15
+ describe("task command", () => {
16
+ let tempDir: string
17
+ let originalCwd: string
18
+ let originalConfigDir: string | undefined
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await createTempDir()
22
+ originalCwd = process.cwd()
23
+ originalConfigDir = process.env.AGENCY_CONFIG_DIR
24
+ // Use a temp config dir to avoid interference from user's actual config
25
+ process.env.AGENCY_CONFIG_DIR = await createTempDir()
26
+ })
27
+
28
+ afterEach(async () => {
29
+ process.chdir(originalCwd)
30
+ if (originalConfigDir !== undefined) {
31
+ process.env.AGENCY_CONFIG_DIR = originalConfigDir
32
+ } else {
33
+ delete process.env.AGENCY_CONFIG_DIR
34
+ }
35
+ if (
36
+ process.env.AGENCY_CONFIG_DIR &&
37
+ process.env.AGENCY_CONFIG_DIR !== originalConfigDir
38
+ ) {
39
+ await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
40
+ }
41
+ await cleanupTempDir(tempDir)
42
+ })
43
+
44
+ describe("without path argument", () => {
45
+ test("creates AGENTS.md at git root", async () => {
46
+ await initGitRepo(tempDir)
47
+ process.chdir(tempDir)
48
+
49
+ await initAgency(tempDir, "test")
50
+
51
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
52
+
53
+ expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
54
+ })
55
+
56
+ test("creates AGENTS.md with default content", async () => {
57
+ await initGitRepo(tempDir)
58
+ process.chdir(tempDir)
59
+
60
+ await initAgency(tempDir, "test")
61
+
62
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
63
+
64
+ const content = await readFile(join(tempDir, "AGENTS.md"))
65
+ expect(content).toContain("Repo Instructions")
66
+ expect(content).toContain("AGENCY.md")
67
+ })
68
+
69
+ test("creates file at git root even when run from subdirectory", async () => {
70
+ await initGitRepo(tempDir)
71
+ const subdir = await createSubdir(tempDir, "subdir")
72
+ process.chdir(subdir)
73
+
74
+ await initAgency(tempDir, "test")
75
+
76
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
77
+
78
+ expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
79
+ expect(await fileExists(join(subdir, "AGENTS.md"))).toBe(false)
80
+ })
81
+
82
+ test("does not overwrite existing AGENTS.md", async () => {
83
+ await initGitRepo(tempDir)
84
+ process.chdir(tempDir)
85
+
86
+ const existingContent = "# Existing content"
87
+ await Bun.write(join(tempDir, "AGENTS.md"), existingContent)
88
+
89
+ await initAgency(tempDir, "test")
90
+
91
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
92
+
93
+ const content = await readFile(join(tempDir, "AGENTS.md"))
94
+ expect(content).toBe(existingContent)
95
+ })
96
+
97
+ test("throws error when not in a git repository", async () => {
98
+ process.chdir(tempDir)
99
+
100
+ await expect(runTestEffect(task({ silent: true }))).rejects.toThrow(
101
+ "Not in a git repository",
102
+ )
103
+ })
104
+ })
105
+
106
+ describe("with path argument", () => {
107
+ test("creates file at specified git root", async () => {
108
+ await initGitRepo(tempDir)
109
+
110
+ await initAgency(tempDir, "test")
111
+ await runTestEffect(
112
+ task({
113
+ path: tempDir,
114
+ silent: true,
115
+ branch: "test-feature",
116
+ }),
117
+ )
118
+
119
+ expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
120
+ })
121
+
122
+ test("throws error when path is not a git repository root", async () => {
123
+ await initGitRepo(tempDir)
124
+ const subdir = await createSubdir(tempDir, "subdir")
125
+
126
+ expect(
127
+ runTestEffect(task({ path: subdir, silent: true })),
128
+ ).rejects.toThrow("not the root of a git repository")
129
+ })
130
+
131
+ test("throws error when path is not a git repository at all", async () => {
132
+ expect(
133
+ runTestEffect(task({ path: tempDir, silent: true })),
134
+ ).rejects.toThrow("not the root of a git repository")
135
+ })
136
+
137
+ test("resolves relative paths correctly", async () => {
138
+ await initGitRepo(tempDir)
139
+ const subdir = await createSubdir(tempDir, "subdir")
140
+ process.chdir(subdir)
141
+
142
+ await initAgency(tempDir, "test")
143
+ await runTestEffect(
144
+ task({
145
+ path: "..",
146
+ silent: true,
147
+ branch: "test-feature",
148
+ }),
149
+ )
150
+
151
+ expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
152
+ })
153
+ })
154
+
155
+ describe("opencode.json support", () => {
156
+ test("creates opencode.json at git root", async () => {
157
+ await initGitRepo(tempDir)
158
+ process.chdir(tempDir)
159
+
160
+ await initAgency(tempDir, "test")
161
+
162
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
163
+
164
+ expect(await fileExists(join(tempDir, "opencode.json"))).toBe(true)
165
+ })
166
+
167
+ test("creates opencode.json with default content", async () => {
168
+ await initGitRepo(tempDir)
169
+ process.chdir(tempDir)
170
+
171
+ await initAgency(tempDir, "test")
172
+
173
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
174
+
175
+ const content = await readFile(join(tempDir, "opencode.json"))
176
+ const parsed = JSON.parse(content)
177
+
178
+ expect(parsed.$schema).toBe("https://opencode.ai/config.json")
179
+ expect(parsed.instructions).toEqual(["AGENCY.md", "TASK.md"])
180
+ })
181
+
182
+ test("merges with existing opencode.json", async () => {
183
+ await initGitRepo(tempDir)
184
+ process.chdir(tempDir)
185
+
186
+ const existingConfig = {
187
+ custom: "config",
188
+ }
189
+ await Bun.write(
190
+ join(tempDir, "opencode.json"),
191
+ JSON.stringify(existingConfig),
192
+ )
193
+
194
+ await initAgency(tempDir, "test")
195
+
196
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
197
+
198
+ const content = await readFile(join(tempDir, "opencode.json"))
199
+ const parsed = JSON.parse(content)
200
+
201
+ // Should preserve existing properties
202
+ expect(parsed.custom).toBe("config")
203
+
204
+ // Should add our instructions
205
+ expect(parsed.instructions).toContain("AGENCY.md")
206
+ expect(parsed.instructions).toContain("TASK.md")
207
+ })
208
+
209
+ test("uses opencode.json from template directory if it exists", async () => {
210
+ await initGitRepo(tempDir)
211
+ process.chdir(tempDir)
212
+
213
+ const configDir = process.env.AGENCY_CONFIG_DIR!
214
+ const templateDir = join(configDir, "templates", "custom-template")
215
+ await Bun.spawn(["mkdir", "-p", templateDir], {
216
+ stdout: "pipe",
217
+ stderr: "pipe",
218
+ }).exited
219
+
220
+ const customConfig = JSON.stringify({
221
+ $schema: "https://opencode.ai/config.json",
222
+ instructions: ["CUSTOM.md"],
223
+ })
224
+ await Bun.write(join(templateDir, "opencode.json"), customConfig)
225
+
226
+ await initAgency(tempDir, "custom-template")
227
+
228
+ await runTestEffect(
229
+ task({
230
+ silent: true,
231
+ branch: "test-feature",
232
+ }),
233
+ )
234
+
235
+ const content = await readFile(join(tempDir, "opencode.json"))
236
+ expect(content).toBe(customConfig)
237
+ })
238
+
239
+ test("merges with existing opencode.jsonc", async () => {
240
+ await initGitRepo(tempDir)
241
+ process.chdir(tempDir)
242
+
243
+ const existingConfig = {
244
+ custom: "config",
245
+ existing: "property",
246
+ }
247
+ await Bun.write(
248
+ join(tempDir, "opencode.jsonc"),
249
+ JSON.stringify(existingConfig, null, 2),
250
+ )
251
+
252
+ await initAgency(tempDir, "test")
253
+
254
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
255
+
256
+ // Should update opencode.jsonc, not create opencode.json
257
+ expect(await fileExists(join(tempDir, "opencode.jsonc"))).toBe(true)
258
+ expect(await fileExists(join(tempDir, "opencode.json"))).toBe(false)
259
+
260
+ const content = await readFile(join(tempDir, "opencode.jsonc"))
261
+ const parsed = JSON.parse(content)
262
+
263
+ // Should preserve existing properties
264
+ expect(parsed.custom).toBe("config")
265
+ expect(parsed.existing).toBe("property")
266
+
267
+ // Should add our instructions
268
+ expect(parsed.instructions).toContain("AGENCY.md")
269
+ expect(parsed.instructions).toContain("TASK.md")
270
+ })
271
+
272
+ test("prefers opencode.jsonc over opencode.json when both exist", async () => {
273
+ await initGitRepo(tempDir)
274
+ process.chdir(tempDir)
275
+
276
+ // Create both files
277
+ const jsoncConfig = {
278
+ custom: "jsonc-config",
279
+ }
280
+ const jsonConfig = {
281
+ custom: "json-config",
282
+ }
283
+ await Bun.write(
284
+ join(tempDir, "opencode.jsonc"),
285
+ JSON.stringify(jsoncConfig, null, 2),
286
+ )
287
+ await Bun.write(
288
+ join(tempDir, "opencode.json"),
289
+ JSON.stringify(jsonConfig, null, 2),
290
+ )
291
+
292
+ await initAgency(tempDir, "test")
293
+
294
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
295
+
296
+ // Should merge with opencode.jsonc (not opencode.json)
297
+ const jsoncContent = await readFile(join(tempDir, "opencode.jsonc"))
298
+ const jsoncParsed = JSON.parse(jsoncContent)
299
+
300
+ expect(jsoncParsed.custom).toBe("jsonc-config")
301
+ expect(jsoncParsed.instructions).toContain("AGENCY.md")
302
+
303
+ // opencode.json should remain unchanged
304
+ const jsonContent = await readFile(join(tempDir, "opencode.json"))
305
+ expect(jsonContent).toBe(JSON.stringify(jsonConfig, null, 2))
306
+ })
307
+
308
+ test("handles opencode.jsonc with comments", async () => {
309
+ await initGitRepo(tempDir)
310
+ process.chdir(tempDir)
311
+
312
+ const jsoncWithComments = `{
313
+ // This is a comment
314
+ "custom": "config",
315
+ /* Multi-line
316
+ comment */
317
+ "existing": "property"
318
+ }`
319
+ await Bun.write(join(tempDir, "opencode.jsonc"), jsoncWithComments)
320
+
321
+ await initAgency(tempDir, "test")
322
+
323
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
324
+
325
+ const content = await readFile(join(tempDir, "opencode.jsonc"))
326
+ const parsed = JSON.parse(content)
327
+
328
+ // Should preserve existing properties
329
+ expect(parsed.custom).toBe("config")
330
+ expect(parsed.existing).toBe("property")
331
+
332
+ // Should add our instructions
333
+ expect(parsed.instructions).toContain("AGENCY.md")
334
+ expect(parsed.instructions).toContain("TASK.md")
335
+ })
336
+
337
+ test("merges with .opencode/opencode.json", async () => {
338
+ await initGitRepo(tempDir)
339
+ process.chdir(tempDir)
340
+
341
+ // Create .opencode directory with opencode.json
342
+ const dotOpencodeDir = join(tempDir, ".opencode")
343
+ await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
344
+ stdout: "pipe",
345
+ stderr: "pipe",
346
+ }).exited
347
+
348
+ const existingConfig = {
349
+ custom: "config-in-dotdir",
350
+ }
351
+ await Bun.write(
352
+ join(dotOpencodeDir, "opencode.json"),
353
+ JSON.stringify(existingConfig, null, 2),
354
+ )
355
+
356
+ await initAgency(tempDir, "test")
357
+
358
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
359
+
360
+ // Should update .opencode/opencode.json, not create root opencode.json
361
+ expect(await fileExists(join(dotOpencodeDir, "opencode.json"))).toBe(true)
362
+ expect(await fileExists(join(tempDir, "opencode.json"))).toBe(false)
363
+
364
+ const content = await readFile(join(dotOpencodeDir, "opencode.json"))
365
+ const parsed = JSON.parse(content)
366
+
367
+ // Should preserve existing properties
368
+ expect(parsed.custom).toBe("config-in-dotdir")
369
+
370
+ // Should add our instructions
371
+ expect(parsed.instructions).toContain("AGENCY.md")
372
+ expect(parsed.instructions).toContain("TASK.md")
373
+ })
374
+
375
+ test("merges with .opencode/opencode.jsonc", async () => {
376
+ await initGitRepo(tempDir)
377
+ process.chdir(tempDir)
378
+
379
+ // Create .opencode directory with opencode.jsonc
380
+ const dotOpencodeDir = join(tempDir, ".opencode")
381
+ await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
382
+ stdout: "pipe",
383
+ stderr: "pipe",
384
+ }).exited
385
+
386
+ const existingConfig = {
387
+ custom: "jsonc-in-dotdir",
388
+ }
389
+ await Bun.write(
390
+ join(dotOpencodeDir, "opencode.jsonc"),
391
+ JSON.stringify(existingConfig, null, 2),
392
+ )
393
+
394
+ await initAgency(tempDir, "test")
395
+
396
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
397
+
398
+ // Should update .opencode/opencode.jsonc, not create root opencode.json
399
+ expect(await fileExists(join(dotOpencodeDir, "opencode.jsonc"))).toBe(
400
+ true,
401
+ )
402
+ expect(await fileExists(join(tempDir, "opencode.json"))).toBe(false)
403
+
404
+ const content = await readFile(join(dotOpencodeDir, "opencode.jsonc"))
405
+ const parsed = JSON.parse(content)
406
+
407
+ // Should preserve existing properties
408
+ expect(parsed.custom).toBe("jsonc-in-dotdir")
409
+
410
+ // Should add our instructions
411
+ expect(parsed.instructions).toContain("AGENCY.md")
412
+ expect(parsed.instructions).toContain("TASK.md")
413
+ })
414
+
415
+ test("prefers .opencode/opencode.jsonc over root opencode.json", async () => {
416
+ await initGitRepo(tempDir)
417
+ process.chdir(tempDir)
418
+
419
+ // Create .opencode directory with opencode.jsonc
420
+ const dotOpencodeDir = join(tempDir, ".opencode")
421
+ await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
422
+ stdout: "pipe",
423
+ stderr: "pipe",
424
+ }).exited
425
+
426
+ const dotOpencodeConfig = {
427
+ custom: "dotdir-jsonc",
428
+ }
429
+ const rootJsonConfig = {
430
+ custom: "root-json",
431
+ }
432
+
433
+ await Bun.write(
434
+ join(dotOpencodeDir, "opencode.jsonc"),
435
+ JSON.stringify(dotOpencodeConfig, null, 2),
436
+ )
437
+ await Bun.write(
438
+ join(tempDir, "opencode.json"),
439
+ JSON.stringify(rootJsonConfig, null, 2),
440
+ )
441
+
442
+ await initAgency(tempDir, "test")
443
+
444
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
445
+
446
+ // Should merge with .opencode/opencode.jsonc (not root opencode.json)
447
+ const dotDirContent = await readFile(
448
+ join(dotOpencodeDir, "opencode.jsonc"),
449
+ )
450
+ const dotDirParsed = JSON.parse(dotDirContent)
451
+
452
+ expect(dotDirParsed.custom).toBe("dotdir-jsonc")
453
+ expect(dotDirParsed.instructions).toContain("AGENCY.md")
454
+
455
+ // root opencode.json should remain unchanged
456
+ const rootContent = await readFile(join(tempDir, "opencode.json"))
457
+ expect(rootContent).toBe(JSON.stringify(rootJsonConfig, null, 2))
458
+ })
459
+
460
+ test("prefers .opencode/opencode.json over root opencode.jsonc", async () => {
461
+ await initGitRepo(tempDir)
462
+ process.chdir(tempDir)
463
+
464
+ // Create .opencode directory with opencode.json
465
+ const dotOpencodeDir = join(tempDir, ".opencode")
466
+ await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
467
+ stdout: "pipe",
468
+ stderr: "pipe",
469
+ }).exited
470
+
471
+ const dotOpencodeConfig = {
472
+ custom: "dotdir-json",
473
+ }
474
+ const rootJsoncConfig = {
475
+ custom: "root-jsonc",
476
+ }
477
+
478
+ await Bun.write(
479
+ join(dotOpencodeDir, "opencode.json"),
480
+ JSON.stringify(dotOpencodeConfig, null, 2),
481
+ )
482
+ await Bun.write(
483
+ join(tempDir, "opencode.jsonc"),
484
+ JSON.stringify(rootJsoncConfig, null, 2),
485
+ )
486
+
487
+ await initAgency(tempDir, "test")
488
+
489
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
490
+
491
+ // Should merge with .opencode/opencode.json (not root opencode.jsonc)
492
+ const dotDirContent = await readFile(
493
+ join(dotOpencodeDir, "opencode.json"),
494
+ )
495
+ const dotDirParsed = JSON.parse(dotDirContent)
496
+
497
+ expect(dotDirParsed.custom).toBe("dotdir-json")
498
+ expect(dotDirParsed.instructions).toContain("AGENCY.md")
499
+
500
+ // root opencode.jsonc should remain unchanged
501
+ const rootContent = await readFile(join(tempDir, "opencode.jsonc"))
502
+ expect(rootContent).toBe(JSON.stringify(rootJsoncConfig, null, 2))
503
+ })
504
+
505
+ test("adds .opencode/opencode.json to injectedFiles", async () => {
506
+ await initGitRepo(tempDir)
507
+ process.chdir(tempDir)
508
+
509
+ // Create .opencode directory with opencode.json
510
+ const dotOpencodeDir = join(tempDir, ".opencode")
511
+ await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
512
+ stdout: "pipe",
513
+ stderr: "pipe",
514
+ }).exited
515
+
516
+ await Bun.write(
517
+ join(dotOpencodeDir, "opencode.json"),
518
+ JSON.stringify({ custom: "config" }, null, 2),
519
+ )
520
+
521
+ await initAgency(tempDir, "test")
522
+
523
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
524
+
525
+ // Check agency.json to verify .opencode/opencode.json is in injectedFiles
526
+ const agencyJsonContent = await readFile(join(tempDir, "agency.json"))
527
+ const metadata = JSON.parse(agencyJsonContent)
528
+
529
+ expect(metadata.injectedFiles).toContain(".opencode/opencode.json")
530
+ })
531
+ })
532
+
533
+ describe("TASK.md support", () => {
534
+ test("creates TASK.md at git root", async () => {
535
+ await initGitRepo(tempDir)
536
+ process.chdir(tempDir)
537
+
538
+ await initAgency(tempDir, "test")
539
+
540
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
541
+
542
+ expect(await fileExists(join(tempDir, "TASK.md"))).toBe(true)
543
+ })
544
+
545
+ test("creates TASK.md with placeholder when no task provided", async () => {
546
+ await initGitRepo(tempDir)
547
+ process.chdir(tempDir)
548
+
549
+ await initAgency(tempDir, "test")
550
+
551
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
552
+
553
+ const content = await readFile(join(tempDir, "TASK.md"))
554
+ expect(content).toContain("{task}")
555
+ })
556
+
557
+ test("creates TASK.md with task description when provided", async () => {
558
+ await initGitRepo(tempDir)
559
+ process.chdir(tempDir)
560
+
561
+ await initAgency(tempDir, "test")
562
+ await runTestEffect(
563
+ task({
564
+ silent: true,
565
+ task: "Build new feature",
566
+ branch: "test-feature",
567
+ }),
568
+ )
569
+
570
+ const content = await readFile(join(tempDir, "TASK.md"))
571
+ expect(content).toContain("Build new feature")
572
+ expect(content).not.toContain("{task}")
573
+ })
574
+
575
+ test("skips TASK.md if it already exists", async () => {
576
+ await initGitRepo(tempDir)
577
+ process.chdir(tempDir)
578
+
579
+ const existingContent = "# Existing TASK"
580
+ await Bun.write(join(tempDir, "TASK.md"), existingContent)
581
+
582
+ await initAgency(tempDir, "test")
583
+
584
+ // Should succeed but skip TASK.md
585
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
586
+
587
+ // TASK.md should not be overwritten
588
+ const content = await readFile(join(tempDir, "TASK.md"))
589
+ expect(content).toBe(existingContent)
590
+
591
+ // Other files should still be created
592
+ expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
593
+ })
594
+
595
+ test("uses TASK.md from template directory if it exists", async () => {
596
+ await initGitRepo(tempDir)
597
+ process.chdir(tempDir)
598
+
599
+ const configDir = process.env.AGENCY_CONFIG_DIR!
600
+ const templateDir = join(configDir, "templates", "custom-template")
601
+ await Bun.spawn(["mkdir", "-p", templateDir], {
602
+ stdout: "pipe",
603
+ stderr: "pipe",
604
+ }).exited
605
+
606
+ const customTask = "# Custom Task Content"
607
+ await Bun.write(join(templateDir, "TASK.md"), customTask)
608
+
609
+ await initAgency(tempDir, "custom-template")
610
+
611
+ await runTestEffect(
612
+ task({
613
+ silent: true,
614
+ branch: "test-feature",
615
+ }),
616
+ )
617
+
618
+ const content = await readFile(join(tempDir, "TASK.md"))
619
+ expect(content).toBe(customTask)
620
+ })
621
+ })
622
+
623
+ describe("silent mode", () => {
624
+ test("silent flag suppresses output", async () => {
625
+ await initGitRepo(tempDir)
626
+ process.chdir(tempDir)
627
+
628
+ // Capture console.log calls
629
+ const logs: string[] = []
630
+ const originalLog = console.log
631
+ console.log = (...args: any[]) => logs.push(args.join(" "))
632
+
633
+ await initAgency(tempDir, "test")
634
+
635
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
636
+
637
+ console.log = originalLog
638
+
639
+ expect(logs.length).toBe(0)
640
+ expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
641
+ })
642
+
643
+ test("without silent flag produces output", async () => {
644
+ await initGitRepo(tempDir)
645
+ process.chdir(tempDir)
646
+
647
+ // Create a feature branch to avoid the main branch check
648
+ await Bun.spawn(["git", "checkout", "-b", "test-feature"], {
649
+ cwd: tempDir,
650
+ stdout: "pipe",
651
+ stderr: "pipe",
652
+ }).exited
653
+
654
+ // Capture console.log calls
655
+ const logs: string[] = []
656
+ const originalLog = console.log
657
+ console.log = (...args: any[]) => logs.push(args.join(" "))
658
+
659
+ // Provide task to avoid interactive prompt
660
+ await initAgency(tempDir, "test")
661
+ await runTestEffect(
662
+ task({
663
+ silent: false,
664
+ task: "Test task",
665
+ branch: "test-feature",
666
+ }),
667
+ )
668
+
669
+ console.log = originalLog
670
+
671
+ expect(logs.length).toBeGreaterThan(0)
672
+ expect(logs.some((log) => log.includes("Created"))).toBe(true)
673
+ })
674
+ })
675
+
676
+ describe("branch handling", () => {
677
+ test("fails when on main branch without branch name", async () => {
678
+ await initGitRepo(tempDir)
679
+ process.chdir(tempDir)
680
+
681
+ await initAgency(tempDir, "test")
682
+ await expect(runTestEffect(task({ silent: true }))).rejects.toThrow(
683
+ "main branch",
684
+ )
685
+ })
686
+
687
+ test("creates branch when on main branch with branch name provided", async () => {
688
+ await initGitRepo(tempDir)
689
+ process.chdir(tempDir)
690
+
691
+ await initAgency(tempDir, "test")
692
+
693
+ await runTestEffect(task({ silent: true, branch: "my-feature" }))
694
+
695
+ // Verify we're now on the new branch
696
+ const proc = Bun.spawn(["git", "branch", "--show-current"], {
697
+ cwd: tempDir,
698
+ stdout: "pipe",
699
+ stderr: "pipe",
700
+ })
701
+ await proc.exited
702
+ const currentBranch = await new Response(proc.stdout).text()
703
+ expect(currentBranch.trim()).toBe("agency/my-feature")
704
+
705
+ // Verify files were created
706
+ expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
707
+ })
708
+
709
+ test("fails early when branch already exists", async () => {
710
+ await initGitRepo(tempDir)
711
+ process.chdir(tempDir)
712
+
713
+ // Create a feature branch first (using source pattern)
714
+ await Bun.spawn(["git", "checkout", "-b", "agency/existing-branch"], {
715
+ cwd: tempDir,
716
+ stdout: "pipe",
717
+ stderr: "pipe",
718
+ }).exited
719
+
720
+ // Switch back to main
721
+ await Bun.spawn(["git", "checkout", "main"], {
722
+ cwd: tempDir,
723
+ stdout: "pipe",
724
+ stderr: "pipe",
725
+ }).exited
726
+
727
+ // Try to create a branch with the same name
728
+ await expect(
729
+ (async () => {
730
+ await initAgency(tempDir, "test")
731
+ return await runTestEffect(
732
+ task({
733
+ silent: true,
734
+ branch: "existing-branch",
735
+ task: "This should not be asked for",
736
+ }),
737
+ )
738
+ })(),
739
+ ).rejects.toThrow("already exists")
740
+ })
741
+
742
+ test("succeeds when already on a feature branch", async () => {
743
+ await initGitRepo(tempDir)
744
+ process.chdir(tempDir)
745
+
746
+ // Create and switch to feature branch
747
+ await Bun.spawn(["git", "checkout", "-b", "feature-branch"], {
748
+ cwd: tempDir,
749
+ stdout: "pipe",
750
+ stderr: "pipe",
751
+ }).exited
752
+
753
+ // Should succeed without needing branch option
754
+ await initAgency(tempDir, "test")
755
+
756
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
757
+
758
+ expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
759
+ })
760
+ })
761
+
762
+ describe("template-based source files", () => {
763
+ let configDir: string
764
+ let originalEnv: string | undefined
765
+
766
+ beforeEach(async () => {
767
+ // Create a temporary config directory
768
+ configDir = await createTempDir()
769
+ originalEnv = process.env.AGENCY_CONFIG_DIR
770
+ process.env.AGENCY_CONFIG_DIR = configDir
771
+ })
772
+
773
+ afterEach(async () => {
774
+ // Restore original env
775
+ if (originalEnv !== undefined) {
776
+ process.env.AGENCY_CONFIG_DIR = originalEnv
777
+ } else {
778
+ delete process.env.AGENCY_CONFIG_DIR
779
+ }
780
+ await cleanupTempDir(configDir)
781
+ })
782
+
783
+ test("uses AGENTS.md from template directory if it exists", async () => {
784
+ await initGitRepo(tempDir)
785
+ process.chdir(tempDir)
786
+
787
+ // Create template with custom content
788
+ const templateDir = join(configDir, "templates", "custom")
789
+ await Bun.spawn(["mkdir", "-p", templateDir], {
790
+ stdout: "pipe",
791
+ stderr: "pipe",
792
+ }).exited
793
+
794
+ const sourceContent = "# Custom AGENTS.md content\nThis is from template"
795
+ await Bun.write(join(templateDir, "AGENTS.md"), sourceContent)
796
+
797
+ await initAgency(tempDir, "custom")
798
+
799
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
800
+
801
+ const content = await readFile(join(tempDir, "AGENTS.md"))
802
+ expect(content).toBe(sourceContent)
803
+ })
804
+
805
+ test("uses default content when template doesn't exist yet", async () => {
806
+ await initGitRepo(tempDir)
807
+ process.chdir(tempDir)
808
+
809
+ await initAgency(tempDir, "custom-template")
810
+
811
+ await runTestEffect(
812
+ task({
813
+ silent: true,
814
+ branch: "test-feature",
815
+ }),
816
+ )
817
+
818
+ const agentsContent = await readFile(join(tempDir, "AGENTS.md"))
819
+
820
+ expect(agentsContent).toContain("Repo Instructions")
821
+ })
822
+
823
+ test("does not populate template directory automatically on first use", async () => {
824
+ await initGitRepo(tempDir)
825
+ process.chdir(tempDir)
826
+
827
+ await initAgency(tempDir, "auto-created")
828
+
829
+ await runTestEffect(
830
+ task({
831
+ silent: true,
832
+ branch: "test-feature",
833
+ }),
834
+ )
835
+
836
+ // Template should NOT have AGENTS.md (not populated automatically)
837
+ const templateDir = join(configDir, "templates", "auto-created")
838
+ const templateAgentsExists = await fileExists(
839
+ join(templateDir, "AGENTS.md"),
840
+ )
841
+ expect(templateAgentsExists).toBe(false)
842
+
843
+ // Files in repo should use default content (not from template)
844
+ const agentsContent = await readFile(join(tempDir, "AGENTS.md"))
845
+ expect(agentsContent).toContain("Repo Instructions")
846
+ })
847
+
848
+ test("excludes AGENCY.md and TASK.md from injectedFiles metadata", async () => {
849
+ await initGitRepo(tempDir)
850
+ process.chdir(tempDir)
851
+
852
+ await initAgency(tempDir, "test")
853
+
854
+ await runTestEffect(
855
+ task({
856
+ silent: true,
857
+ branch: "test-feature",
858
+ }),
859
+ )
860
+
861
+ // Read agency.json metadata
862
+ const metadata = JSON.parse(await readFile(join(tempDir, "agency.json")))
863
+
864
+ // AGENCY.md and TASK.md should NOT be in injectedFiles
865
+ expect(metadata.injectedFiles).not.toContain("AGENCY.md")
866
+ expect(metadata.injectedFiles).not.toContain("TASK.md")
867
+
868
+ // opencode.json should be in injectedFiles
869
+ expect(metadata.injectedFiles).toContain("opencode.json")
870
+ })
871
+ })
872
+ })