@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,372 @@
1
+ import { describe, test, expect, afterEach } from "bun:test"
2
+ import {
3
+ makeSourceBranchName,
4
+ extractCleanBranch,
5
+ makeEmitBranchName,
6
+ extractCleanFromEmit,
7
+ resolveBranchPairWithAgencyJson,
8
+ } from "./pr-branch"
9
+ import {
10
+ createTempDir,
11
+ cleanupTempDir,
12
+ initGitRepo,
13
+ runTestEffect,
14
+ } from "../test-utils"
15
+ import { join } from "path"
16
+
17
+ describe("makeSourceBranchName", () => {
18
+ test("replaces %branch% placeholder with branch name", () => {
19
+ expect(makeSourceBranchName("main", "agency/%branch%")).toBe("agency/main")
20
+ expect(makeSourceBranchName("feature-foo", "wip/%branch%")).toBe(
21
+ "wip/feature-foo",
22
+ )
23
+ expect(makeSourceBranchName("main", "%branch%/dev")).toBe("main/dev")
24
+ })
25
+
26
+ test("treats pattern as prefix when %branch% is not present", () => {
27
+ expect(makeSourceBranchName("main", "agency/")).toBe("agency/main")
28
+ expect(makeSourceBranchName("feature-foo", "wip-")).toBe("wip-feature-foo")
29
+ })
30
+
31
+ test("handles empty branch name", () => {
32
+ expect(makeSourceBranchName("", "agency/%branch%")).toBe("agency/")
33
+ expect(makeSourceBranchName("", "agency/")).toBe("agency/")
34
+ })
35
+ })
36
+
37
+ describe("extractCleanBranch", () => {
38
+ describe("with %branch% placeholder", () => {
39
+ test("extracts clean branch from source branch name", () => {
40
+ expect(extractCleanBranch("agency/main", "agency/%branch%")).toBe("main")
41
+ expect(extractCleanBranch("wip/feature-foo", "wip/%branch%")).toBe(
42
+ "feature-foo",
43
+ )
44
+ expect(extractCleanBranch("main/dev", "%branch%/dev")).toBe("main")
45
+ })
46
+
47
+ test("returns null when source branch name doesn't match pattern", () => {
48
+ expect(extractCleanBranch("main", "agency/%branch%")).toBeNull()
49
+ expect(extractCleanBranch("feature-foo", "wip/%branch%")).toBeNull()
50
+ expect(extractCleanBranch("agency/main", "wip/%branch%")).toBeNull()
51
+ })
52
+
53
+ test("returns null for empty clean branch", () => {
54
+ expect(extractCleanBranch("agency/", "agency/%branch%")).toBeNull()
55
+ expect(extractCleanBranch("wip/", "wip/%branch%")).toBeNull()
56
+ })
57
+
58
+ test("handles complex patterns", () => {
59
+ expect(
60
+ extractCleanBranch("pr-feature-foo-ready", "pr-%branch%-ready"),
61
+ ).toBe("feature-foo")
62
+ expect(
63
+ extractCleanBranch("PR/feature/foo/ready", "PR/%branch%/ready"),
64
+ ).toBe("feature/foo")
65
+ })
66
+ })
67
+
68
+ describe("without %branch% placeholder (prefix mode)", () => {
69
+ test("extracts clean branch by removing prefix", () => {
70
+ expect(extractCleanBranch("agency/main", "agency/")).toBe("main")
71
+ expect(extractCleanBranch("wip-feature-foo", "wip-")).toBe("feature-foo")
72
+ })
73
+
74
+ test("returns null when branch doesn't start with prefix", () => {
75
+ expect(extractCleanBranch("main", "agency/")).toBeNull()
76
+ expect(extractCleanBranch("feature-foo", "wip-")).toBeNull()
77
+ })
78
+
79
+ test("returns null for empty clean branch", () => {
80
+ expect(extractCleanBranch("agency/", "agency/")).toBeNull()
81
+ expect(extractCleanBranch("wip-", "wip-")).toBeNull()
82
+ })
83
+ })
84
+ })
85
+
86
+ describe("makeEmitBranchName", () => {
87
+ test("returns clean branch when pattern is %branch%", () => {
88
+ expect(makeEmitBranchName("main", "%branch%")).toBe("main")
89
+ expect(makeEmitBranchName("feature-foo", "%branch%")).toBe("feature-foo")
90
+ })
91
+
92
+ test("replaces %branch% placeholder with branch name", () => {
93
+ expect(makeEmitBranchName("feature-foo", "%branch%--PR")).toBe(
94
+ "feature-foo--PR",
95
+ )
96
+ expect(makeEmitBranchName("feature-foo", "PR/%branch%")).toBe(
97
+ "PR/feature-foo",
98
+ )
99
+ })
100
+
101
+ test("treats pattern as suffix when %branch% is not present", () => {
102
+ expect(makeEmitBranchName("feature-foo", "--PR")).toBe("feature-foo--PR")
103
+ expect(makeEmitBranchName("feature-foo", "-pr")).toBe("feature-foo-pr")
104
+ })
105
+
106
+ test("handles empty branch name", () => {
107
+ expect(makeEmitBranchName("", "%branch%")).toBe("")
108
+ expect(makeEmitBranchName("", "%branch%--PR")).toBe("--PR")
109
+ expect(makeEmitBranchName("", "--PR")).toBe("--PR")
110
+ })
111
+ })
112
+
113
+ describe("extractCleanFromEmit", () => {
114
+ test("returns emit branch when pattern is %branch%", () => {
115
+ expect(extractCleanFromEmit("main", "%branch%")).toBe("main")
116
+ expect(extractCleanFromEmit("feature-foo", "%branch%")).toBe("feature-foo")
117
+ })
118
+
119
+ describe("with %branch% placeholder", () => {
120
+ test("extracts clean branch from emit branch name", () => {
121
+ expect(extractCleanFromEmit("feature-foo--PR", "%branch%--PR")).toBe(
122
+ "feature-foo",
123
+ )
124
+ expect(extractCleanFromEmit("PR/feature-foo", "PR/%branch%")).toBe(
125
+ "feature-foo",
126
+ )
127
+ })
128
+
129
+ test("returns null when emit branch name doesn't match pattern", () => {
130
+ expect(extractCleanFromEmit("feature-foo", "%branch%--PR")).toBeNull()
131
+ expect(extractCleanFromEmit("feature-foo--PR", "PR/%branch%")).toBeNull()
132
+ expect(extractCleanFromEmit("main", "%branch%--PR")).toBeNull()
133
+ })
134
+
135
+ test("returns null for empty clean branch", () => {
136
+ expect(extractCleanFromEmit("--PR", "%branch%--PR")).toBeNull()
137
+ expect(extractCleanFromEmit("PR/", "PR/%branch%")).toBeNull()
138
+ })
139
+
140
+ test("handles complex patterns", () => {
141
+ expect(
142
+ extractCleanFromEmit("pr-feature-foo-ready", "pr-%branch%-ready"),
143
+ ).toBe("feature-foo")
144
+ expect(
145
+ extractCleanFromEmit("PR/feature/foo/ready", "PR/%branch%/ready"),
146
+ ).toBe("feature/foo")
147
+ })
148
+ })
149
+
150
+ describe("without %branch% placeholder (suffix mode)", () => {
151
+ test("extracts clean branch by removing suffix", () => {
152
+ expect(extractCleanFromEmit("feature-foo--PR", "--PR")).toBe(
153
+ "feature-foo",
154
+ )
155
+ expect(extractCleanFromEmit("feature-foo-pr", "-pr")).toBe("feature-foo")
156
+ })
157
+
158
+ test("returns null when branch doesn't end with suffix", () => {
159
+ expect(extractCleanFromEmit("feature-foo", "--PR")).toBeNull()
160
+ expect(extractCleanFromEmit("PR-feature-foo", "--PR")).toBeNull()
161
+ })
162
+
163
+ test("returns null for empty clean branch", () => {
164
+ expect(extractCleanFromEmit("--PR", "--PR")).toBeNull()
165
+ expect(extractCleanFromEmit("-pr", "-pr")).toBeNull()
166
+ })
167
+ })
168
+ })
169
+
170
+ describe("resolveBranchPairWithAgencyJson", () => {
171
+ let tempDir: string
172
+
173
+ afterEach(async () => {
174
+ if (tempDir) {
175
+ await cleanupTempDir(tempDir)
176
+ }
177
+ })
178
+
179
+ test("uses agency.json emitBranch when on source branch", async () => {
180
+ tempDir = await createTempDir()
181
+ await initGitRepo(tempDir)
182
+
183
+ // Create source branch with agency.json
184
+ await Bun.spawn(["git", "checkout", "-b", "agency/main"], {
185
+ cwd: tempDir,
186
+ stdout: "pipe",
187
+ stderr: "pipe",
188
+ }).exited
189
+
190
+ const agencyJson = {
191
+ version: 1,
192
+ injectedFiles: [],
193
+ template: "test",
194
+ createdAt: new Date().toISOString(),
195
+ emitBranch: "main",
196
+ }
197
+ await Bun.write(join(tempDir, "agency.json"), JSON.stringify(agencyJson))
198
+
199
+ const result = await runTestEffect(
200
+ resolveBranchPairWithAgencyJson(
201
+ tempDir,
202
+ "agency/main",
203
+ "agency/%branch%",
204
+ "%branch%",
205
+ ),
206
+ )
207
+
208
+ expect(result.sourceBranch).toBe("agency/main")
209
+ expect(result.emitBranch).toBe("main")
210
+ expect(result.isOnEmitBranch).toBe(false)
211
+ })
212
+
213
+ test("finds source branch by searching for matching emitBranch", async () => {
214
+ tempDir = await createTempDir()
215
+ await initGitRepo(tempDir)
216
+
217
+ // Create a source branch with agency.json
218
+ await Bun.spawn(["git", "checkout", "-b", "agency/feature-bar"], {
219
+ cwd: tempDir,
220
+ stdout: "pipe",
221
+ stderr: "pipe",
222
+ }).exited
223
+
224
+ const agencyJson = {
225
+ version: 1,
226
+ injectedFiles: [],
227
+ template: "test",
228
+ createdAt: new Date().toISOString(),
229
+ emitBranch: "feature-bar",
230
+ }
231
+ await Bun.write(join(tempDir, "agency.json"), JSON.stringify(agencyJson))
232
+ await Bun.spawn(["git", "add", "agency.json"], {
233
+ cwd: tempDir,
234
+ stdout: "pipe",
235
+ stderr: "pipe",
236
+ }).exited
237
+ await Bun.spawn(["git", "commit", "-m", "Add agency.json"], {
238
+ cwd: tempDir,
239
+ stdout: "pipe",
240
+ stderr: "pipe",
241
+ }).exited
242
+
243
+ // Create the emit branch from main (so it doesn't have agency.json)
244
+ await Bun.spawn(["git", "checkout", "main"], {
245
+ cwd: tempDir,
246
+ stdout: "pipe",
247
+ stderr: "pipe",
248
+ }).exited
249
+ await Bun.spawn(["git", "checkout", "-b", "feature-bar"], {
250
+ cwd: tempDir,
251
+ stdout: "pipe",
252
+ stderr: "pipe",
253
+ }).exited
254
+
255
+ const result = await runTestEffect(
256
+ resolveBranchPairWithAgencyJson(
257
+ tempDir,
258
+ "feature-bar",
259
+ "agency/%branch%",
260
+ "%branch%",
261
+ ),
262
+ )
263
+
264
+ expect(result.sourceBranch).toBe("agency/feature-bar")
265
+ expect(result.emitBranch).toBe("feature-bar")
266
+ expect(result.isOnEmitBranch).toBe(true)
267
+ })
268
+
269
+ test("falls back to pattern-based resolution when agency.json not found", async () => {
270
+ tempDir = await createTempDir()
271
+ await initGitRepo(tempDir)
272
+
273
+ // Create source branch without agency.json
274
+ await Bun.spawn(["git", "checkout", "-b", "agency/feature-baz"], {
275
+ cwd: tempDir,
276
+ stdout: "pipe",
277
+ stderr: "pipe",
278
+ }).exited
279
+
280
+ // No agency.json, should fall back to pattern-based resolution
281
+ const result = await runTestEffect(
282
+ resolveBranchPairWithAgencyJson(
283
+ tempDir,
284
+ "agency/feature-baz",
285
+ "agency/%branch%",
286
+ "%branch%",
287
+ ),
288
+ )
289
+
290
+ expect(result.sourceBranch).toBe("agency/feature-baz")
291
+ expect(result.emitBranch).toBe("feature-baz")
292
+ expect(result.isOnEmitBranch).toBe(false)
293
+ })
294
+
295
+ test("treats branch as legacy source when no matching pattern and no source branch exists", async () => {
296
+ tempDir = await createTempDir()
297
+ await initGitRepo(tempDir)
298
+
299
+ // Create a branch without the source pattern (legacy branch)
300
+ // No agency/feature-qux exists, so this is a legacy source branch
301
+ await Bun.spawn(["git", "checkout", "-b", "feature-qux"], {
302
+ cwd: tempDir,
303
+ stdout: "pipe",
304
+ stderr: "pipe",
305
+ }).exited
306
+
307
+ const result = await runTestEffect(
308
+ resolveBranchPairWithAgencyJson(
309
+ tempDir,
310
+ "feature-qux",
311
+ "agency/%branch%",
312
+ "%branch%",
313
+ ),
314
+ )
315
+
316
+ // Legacy branch is treated as source, emit is the same name (with %branch% pattern)
317
+ expect(result.sourceBranch).toBe("feature-qux")
318
+ expect(result.emitBranch).toBe("feature-qux")
319
+ expect(result.isOnEmitBranch).toBe(false)
320
+ })
321
+
322
+ test("handles branches with no agency.json on current branch", async () => {
323
+ tempDir = await createTempDir()
324
+ await initGitRepo(tempDir)
325
+
326
+ // Create source branch, no agency.json
327
+ await Bun.spawn(["git", "checkout", "-b", "agency/main"], {
328
+ cwd: tempDir,
329
+ stdout: "pipe",
330
+ stderr: "pipe",
331
+ }).exited
332
+
333
+ // Just test pattern-based resolution with no agency.json anywhere
334
+ const result = await runTestEffect(
335
+ resolveBranchPairWithAgencyJson(
336
+ tempDir,
337
+ "agency/main",
338
+ "agency/%branch%",
339
+ "%branch%",
340
+ ),
341
+ )
342
+
343
+ expect(result.sourceBranch).toBe("agency/main")
344
+ expect(result.emitBranch).toBe("main")
345
+ expect(result.isOnEmitBranch).toBe(false)
346
+ })
347
+
348
+ test("handles emit pattern with suffix", async () => {
349
+ tempDir = await createTempDir()
350
+ await initGitRepo(tempDir)
351
+
352
+ // Test with custom emit pattern that adds a suffix
353
+ await Bun.spawn(["git", "checkout", "-b", "agency/feature"], {
354
+ cwd: tempDir,
355
+ stdout: "pipe",
356
+ stderr: "pipe",
357
+ }).exited
358
+
359
+ const result = await runTestEffect(
360
+ resolveBranchPairWithAgencyJson(
361
+ tempDir,
362
+ "agency/feature",
363
+ "agency/%branch%",
364
+ "%branch%--PR",
365
+ ),
366
+ )
367
+
368
+ expect(result.sourceBranch).toBe("agency/feature")
369
+ expect(result.emitBranch).toBe("feature--PR")
370
+ expect(result.isOnEmitBranch).toBe(false)
371
+ })
372
+ })