@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,412 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { emit } from "../commands/emit"
4
+ import { task } from "../commands/task"
5
+ import {
6
+ createTempDir,
7
+ cleanupTempDir,
8
+ initGitRepo,
9
+ initAgency,
10
+ fileExists,
11
+ getGitOutput,
12
+ getCurrentBranch,
13
+ createCommit,
14
+ checkoutBranch,
15
+ createBranch,
16
+ addAndCommit,
17
+ setupRemote,
18
+ renameBranch,
19
+ runTestEffect,
20
+ } from "../test-utils"
21
+
22
+ // Cache the git-filter-repo availability check (it doesn't change during test run)
23
+ let hasGitFilterRepoCache: boolean | null = null
24
+ async function checkGitFilterRepo(): Promise<boolean> {
25
+ if (hasGitFilterRepoCache === null) {
26
+ const proc = Bun.spawn(["which", "git-filter-repo"], {
27
+ stdout: "pipe",
28
+ stderr: "pipe",
29
+ })
30
+ await proc.exited
31
+ hasGitFilterRepoCache = proc.exitCode === 0
32
+ }
33
+ return hasGitFilterRepoCache
34
+ }
35
+
36
+ describe("emit command", () => {
37
+ let tempDir: string
38
+ let originalCwd: string
39
+ let hasGitFilterRepo: boolean
40
+
41
+ beforeEach(async () => {
42
+ tempDir = await createTempDir()
43
+ originalCwd = process.cwd()
44
+ process.chdir(tempDir)
45
+
46
+ // Set config path to non-existent file to use defaults
47
+ process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
48
+ // Set config dir to temp dir to avoid picking up user's config files
49
+ process.env.AGENCY_CONFIG_DIR = await createTempDir()
50
+
51
+ // Check if git-filter-repo is available (cached)
52
+ hasGitFilterRepo = await checkGitFilterRepo()
53
+
54
+ // Initialize git repo with main branch (already includes initial commit)
55
+ await initGitRepo(tempDir)
56
+
57
+ // Create a source branch (with agency/ prefix)
58
+ await createBranch(tempDir, "agency/test-feature")
59
+
60
+ // Initialize AGENTS.md and commit in one go
61
+ await initAgency(tempDir, "test")
62
+
63
+ await runTestEffect(task({ silent: true }))
64
+ await addAndCommit(tempDir, "AGENTS.md", "Add AGENTS.md")
65
+
66
+ // Set up origin/main for git-filter-repo
67
+ await setupRemote(tempDir, "origin", tempDir)
68
+ })
69
+
70
+ afterEach(async () => {
71
+ process.chdir(originalCwd)
72
+ delete process.env.AGENCY_CONFIG_PATH
73
+ if (process.env.AGENCY_CONFIG_DIR) {
74
+ await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
75
+ delete process.env.AGENCY_CONFIG_DIR
76
+ }
77
+ await cleanupTempDir(tempDir)
78
+ })
79
+
80
+ describe("basic functionality", () => {
81
+ test("throws error when git-filter-repo is not installed", async () => {
82
+ if (hasGitFilterRepo) {
83
+ // Skip this test if git-filter-repo IS installed
84
+ return
85
+ }
86
+
87
+ await createBranch(tempDir, "agency/feature")
88
+ await createCommit(tempDir, "Feature commit")
89
+
90
+ expect(runTestEffect(emit({ silent: true }))).rejects.toThrow(
91
+ "git-filter-repo is not installed",
92
+ )
93
+ })
94
+
95
+ test("creates emit branch with default name", async () => {
96
+ // Go back to main and create a fresh source branch (no inherited agency.json)
97
+ await checkoutBranch(tempDir, "main")
98
+ await createBranch(tempDir, "agency/feature")
99
+ // Create agency.json for this branch
100
+ await Bun.write(
101
+ join(tempDir, "agency.json"),
102
+ JSON.stringify({
103
+ version: 1,
104
+ injectedFiles: ["AGENTS.md"],
105
+ template: "test",
106
+ createdAt: new Date().toISOString(),
107
+ }),
108
+ )
109
+ await addAndCommit(tempDir, "agency.json", "Feature commit")
110
+
111
+ // Create emit branch (skip filter for speed)
112
+ await runTestEffect(emit({ silent: true, skipFilter: true }))
113
+
114
+ // Check that emit branch exists (default pattern is %branch%, so emit is just "feature")
115
+ const branches = await getGitOutput(tempDir, [
116
+ "branch",
117
+ "--list",
118
+ "feature",
119
+ ])
120
+ expect(branches.trim()).toContain("feature")
121
+
122
+ // Check we're still on the source branch
123
+ const currentBranch = await getCurrentBranch(tempDir)
124
+ expect(currentBranch).toBe("agency/feature")
125
+ })
126
+
127
+ test("creates emit branch with custom name", async () => {
128
+ await createBranch(tempDir, "agency/feature")
129
+ await createCommit(tempDir, "Feature commit")
130
+
131
+ // Skip filter for speed - we're just testing branch creation
132
+ await runTestEffect(
133
+ emit({ branch: "custom-pr", silent: true, skipFilter: true }),
134
+ )
135
+
136
+ const branches = await getGitOutput(tempDir, [
137
+ "branch",
138
+ "--list",
139
+ "custom-pr",
140
+ ])
141
+ expect(branches.trim()).toContain("custom-pr")
142
+
143
+ // Check we're still on the source branch
144
+ const currentBranch = await getCurrentBranch(tempDir)
145
+ expect(currentBranch).toBe("agency/feature")
146
+ })
147
+
148
+ test("completes emit workflow successfully", async () => {
149
+ await createBranch(tempDir, "agency/feature")
150
+ await createCommit(tempDir, "Feature commit")
151
+
152
+ // Should complete without throwing (skip filter for speed)
153
+ await runTestEffect(emit({ silent: true, skipFilter: true }))
154
+
155
+ // Should still be on source branch
156
+ const currentBranch = await getCurrentBranch(tempDir)
157
+ expect(currentBranch).toBe("agency/feature")
158
+ })
159
+
160
+ test("preserves files on source branch after emit", async () => {
161
+ await createBranch(tempDir, "agency/feature")
162
+ await createCommit(tempDir, "Feature commit")
163
+
164
+ // Skip filter for speed - we're testing source branch preservation
165
+ await runTestEffect(emit({ silent: true, skipFilter: true }))
166
+
167
+ // Should still be on source branch
168
+ const currentBranch = await getCurrentBranch(tempDir)
169
+ expect(currentBranch).toBe("agency/feature")
170
+
171
+ // Check that test file still exists on source branch
172
+ expect(await fileExists(join(tempDir, "test.txt"))).toBe(true)
173
+
174
+ const files = await getGitOutput(tempDir, ["ls-files"])
175
+ expect(files).toContain("test.txt")
176
+ })
177
+
178
+ test("original branch remains untouched", async () => {
179
+ await createBranch(tempDir, "agency/feature")
180
+ await createCommit(tempDir, "Feature commit")
181
+
182
+ // Create emit branch (skip filter for speed)
183
+ await runTestEffect(emit({ silent: true, skipFilter: true }))
184
+
185
+ // Should still be on feature branch
186
+ const currentBranch = await getCurrentBranch(tempDir)
187
+ expect(currentBranch).toBe("agency/feature")
188
+
189
+ // Check that AGENTS.md still exist on original branch
190
+ const files = await getGitOutput(tempDir, ["ls-files"])
191
+ expect(files).toContain("AGENTS.md")
192
+ })
193
+
194
+ test("works correctly when run multiple times (recreates emit branch)", async () => {
195
+ await createBranch(tempDir, "agency/feature")
196
+
197
+ // Modify AGENTS.md on feature branch
198
+ await Bun.write(
199
+ join(tempDir, "AGENTS.md"),
200
+ "# Modified by feature branch\n",
201
+ )
202
+ await addAndCommit(tempDir, "AGENTS.md", "Modify AGENTS.md")
203
+
204
+ // Run emit command first time (skip filter for speed)
205
+ await runTestEffect(emit({ silent: true, skipFilter: true }))
206
+
207
+ // Switch back to feature branch
208
+ await checkoutBranch(tempDir, "agency/feature")
209
+
210
+ // Make another commit
211
+ await createCommit(tempDir, "Another feature commit")
212
+
213
+ // Run emit command second time (skip filter for speed)
214
+ await runTestEffect(emit({ silent: true, skipFilter: true }))
215
+
216
+ // Should complete successfully without interactive prompts and stay on source branch
217
+ const currentBranch = await getCurrentBranch(tempDir)
218
+ expect(currentBranch).toBe("agency/feature")
219
+ })
220
+
221
+ test("accepts explicit base branch argument", async () => {
222
+ await createBranch(tempDir, "agency/feature")
223
+ await createCommit(tempDir, "Feature commit")
224
+
225
+ // Create emit branch with explicit base branch (skip filter for speed)
226
+ await runTestEffect(
227
+ emit({ baseBranch: "main", silent: true, skipFilter: true }),
228
+ )
229
+
230
+ // Should stay on source branch
231
+ const currentBranch = await getCurrentBranch(tempDir)
232
+ expect(currentBranch).toBe("agency/feature")
233
+ })
234
+
235
+ test("throws error if provided base branch does not exist", async () => {
236
+ await createBranch(tempDir, "agency/feature")
237
+ await createCommit(tempDir, "Feature commit")
238
+
239
+ // This should fail even with skipFilter since base branch validation happens first
240
+ expect(
241
+ runTestEffect(
242
+ emit({ baseBranch: "nonexistent", silent: true, skipFilter: true }),
243
+ ),
244
+ ).rejects.toThrow("does not exist")
245
+ })
246
+ })
247
+
248
+ // Integration tests that actually run git-filter-repo to verify filtering behavior
249
+ // These are slower but ensure the filtering logic works correctly
250
+ describe("filtering integration (requires git-filter-repo)", () => {
251
+ test("filters AGENTS.md from emit branch", async () => {
252
+ if (!hasGitFilterRepo) {
253
+ console.log("Skipping test: git-filter-repo not installed")
254
+ return
255
+ }
256
+
257
+ // Go back to main and create a fresh source branch
258
+ await checkoutBranch(tempDir, "main")
259
+ await createBranch(tempDir, "agency/feature")
260
+ // Create agency.json with AGENTS.md as managed file
261
+ await Bun.write(
262
+ join(tempDir, "agency.json"),
263
+ JSON.stringify({
264
+ version: 1,
265
+ injectedFiles: ["AGENTS.md"],
266
+ template: "test",
267
+ createdAt: new Date().toISOString(),
268
+ }),
269
+ )
270
+ await Bun.write(join(tempDir, "AGENTS.md"), "# Test AGENTS\n")
271
+ await addAndCommit(tempDir, "agency.json AGENTS.md", "Add agency files")
272
+ // Also create a test.txt file via createCommit
273
+ await createCommit(tempDir, "Feature commit")
274
+
275
+ // Create emit branch (this runs git-filter-repo)
276
+ await runTestEffect(emit({ silent: true }))
277
+
278
+ // Should still be on feature branch
279
+ const currentBranch = await getCurrentBranch(tempDir)
280
+ expect(currentBranch).toBe("agency/feature")
281
+
282
+ // Switch to emit branch to verify files
283
+ await checkoutBranch(tempDir, "feature")
284
+
285
+ // AGENTS.md should be filtered out
286
+ const files = await getGitOutput(tempDir, ["ls-files"])
287
+ expect(files).not.toContain("AGENTS.md")
288
+ expect(files).not.toContain("AGENCY.md")
289
+
290
+ // But test.txt should still exist
291
+ expect(files).toContain("test.txt")
292
+ })
293
+
294
+ test("handles emit branch recreation after source branch rebase", async () => {
295
+ if (!hasGitFilterRepo) {
296
+ console.log("Skipping test: git-filter-repo not installed")
297
+ return
298
+ }
299
+
300
+ // We're on test-feature which has AGENTS.md
301
+ // Add a feature-specific file to avoid conflicts
302
+ await Bun.write(join(tempDir, "feature.txt"), "feature content\n")
303
+ await addAndCommit(tempDir, "feature.txt", "Add feature file")
304
+
305
+ // Store merge-base before advancing main
306
+ const initialMergeBase = await getGitOutput(tempDir, [
307
+ "merge-base",
308
+ "agency/test-feature",
309
+ "main",
310
+ ])
311
+
312
+ // Create initial emit branch
313
+ await runTestEffect(emit({ silent: true, baseBranch: "main" }))
314
+
315
+ // Should still be on test-feature branch
316
+ let currentBranch = await getCurrentBranch(tempDir)
317
+ expect(currentBranch).toBe("agency/test-feature")
318
+
319
+ // Switch to emit branch to verify AGENTS.md is filtered
320
+ await checkoutBranch(tempDir, "test-feature")
321
+
322
+ let files = await getGitOutput(tempDir, ["ls-files"])
323
+ expect(files).not.toContain("AGENTS.md")
324
+ expect(files).toContain("feature.txt")
325
+
326
+ // Switch back to source branch
327
+ await checkoutBranch(tempDir, "agency/test-feature")
328
+
329
+ // Simulate advancing main branch with a different file
330
+ await checkoutBranch(tempDir, "main")
331
+ await Bun.write(join(tempDir, "main-file.txt"), "main content\n")
332
+ await addAndCommit(tempDir, "main-file.txt", "Main branch advancement")
333
+
334
+ // Rebase test-feature onto new main
335
+ await checkoutBranch(tempDir, "agency/test-feature")
336
+ const rebaseProc = Bun.spawn(["git", "rebase", "main"], {
337
+ cwd: tempDir,
338
+ stdout: "pipe",
339
+ stderr: "pipe",
340
+ })
341
+ await rebaseProc.exited
342
+ if (rebaseProc.exitCode !== 0) {
343
+ const stderr = await new Response(rebaseProc.stderr).text()
344
+ throw new Error(`Rebase failed: ${stderr}`)
345
+ }
346
+
347
+ // Verify merge-base has changed after rebase
348
+ const newMergeBase = await getGitOutput(tempDir, [
349
+ "merge-base",
350
+ "agency/test-feature",
351
+ "main",
352
+ ])
353
+ expect(newMergeBase.trim()).not.toBe(initialMergeBase.trim())
354
+
355
+ // Recreate emit branch after rebase (this is where the bug would manifest)
356
+ await runTestEffect(emit({ silent: true, baseBranch: "main" }))
357
+
358
+ // Should still be on test-feature branch
359
+ currentBranch = await getCurrentBranch(tempDir)
360
+ expect(currentBranch).toBe("agency/test-feature")
361
+
362
+ // Switch to emit branch to verify files
363
+ await checkoutBranch(tempDir, "test-feature")
364
+
365
+ // Verify AGENTS.md is still filtered and no extraneous changes
366
+ files = await getGitOutput(tempDir, ["ls-files"])
367
+ expect(files).not.toContain("AGENTS.md")
368
+ expect(files).toContain("feature.txt")
369
+ expect(files).toContain("main-file.txt") // Should have main's file after rebase
370
+
371
+ // Verify that our feature commits exist but AGENTS.md commit is filtered
372
+ const logOutput = await getGitOutput(tempDir, [
373
+ "log",
374
+ "--oneline",
375
+ "main..test-feature",
376
+ ])
377
+ expect(logOutput).toContain("Add feature file")
378
+ expect(logOutput).not.toContain("Add AGENTS.md")
379
+ })
380
+ })
381
+
382
+ describe("error handling", () => {
383
+ test("throws error when not in a git repository", async () => {
384
+ const nonGitDir = await createTempDir()
385
+ process.chdir(nonGitDir)
386
+
387
+ expect(runTestEffect(emit({ silent: true }))).rejects.toThrow(
388
+ "Not in a git repository",
389
+ )
390
+
391
+ await cleanupTempDir(nonGitDir)
392
+ })
393
+ })
394
+
395
+ describe("silent mode", () => {
396
+ test("silent flag suppresses output", async () => {
397
+ await createBranch(tempDir, "agency/feature")
398
+ await createCommit(tempDir, "Feature commit")
399
+
400
+ const logs: string[] = []
401
+ const originalLog = console.log
402
+ console.log = (...args: any[]) => logs.push(args.join(" "))
403
+
404
+ // Skip filter for speed
405
+ await runTestEffect(emit({ silent: true, skipFilter: true }))
406
+
407
+ console.log = originalLog
408
+
409
+ expect(logs.length).toBe(0)
410
+ })
411
+ })
412
+ })