@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,334 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { task } from "../commands/task"
4
+ import { emit } from "../commands/emit"
5
+ import {
6
+ createTempDir,
7
+ cleanupTempDir,
8
+ initGitRepo,
9
+ initAgency,
10
+ runTestEffect,
11
+ createFile,
12
+ runGitCommand,
13
+ getCurrentBranch,
14
+ } from "../test-utils"
15
+
16
+ describe("task command - branching functionality", () => {
17
+ let tempDir: string
18
+ let originalCwd: string
19
+ let originalConfigDir: string | undefined
20
+
21
+ beforeEach(async () => {
22
+ tempDir = await createTempDir()
23
+ originalCwd = process.cwd()
24
+ originalConfigDir = process.env.AGENCY_CONFIG_DIR
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("--from flag", () => {
45
+ test("branches from specified non-agency branch", async () => {
46
+ await initGitRepo(tempDir)
47
+ process.chdir(tempDir)
48
+ await initAgency(tempDir, "test")
49
+
50
+ // Create a feature branch
51
+ await runGitCommand(tempDir, ["git", "checkout", "-b", "feature-base"])
52
+ await createFile(tempDir, "feature.txt", "content")
53
+ await runGitCommand(tempDir, ["git", "add", "."])
54
+ await runGitCommand(tempDir, ["git", "commit", "-m", "Add feature"])
55
+
56
+ // Go back to main
57
+ await runGitCommand(tempDir, ["git", "checkout", "main"])
58
+
59
+ // Create task branch from feature-base
60
+ await runTestEffect(
61
+ task({
62
+ silent: true,
63
+ branch: "my-task",
64
+ from: "feature-base",
65
+ }),
66
+ )
67
+
68
+ const currentBranch = await getCurrentBranch(tempDir)
69
+ expect(currentBranch).toBe("agency/my-task")
70
+
71
+ // Verify feature.txt exists (came from feature-base)
72
+ const featureFile = await Bun.file(join(tempDir, "feature.txt")).text()
73
+ expect(featureFile).toBe("content")
74
+ })
75
+
76
+ test("throws error if specified branch does not exist", async () => {
77
+ await initGitRepo(tempDir)
78
+ process.chdir(tempDir)
79
+ await initAgency(tempDir, "test")
80
+
81
+ await expect(
82
+ runTestEffect(
83
+ task({
84
+ silent: true,
85
+ branch: "my-task",
86
+ from: "nonexistent-branch",
87
+ }),
88
+ ),
89
+ ).rejects.toThrow("does not exist")
90
+ })
91
+
92
+ test("detects agency source branch and uses its emit branch", async () => {
93
+ await initGitRepo(tempDir)
94
+ process.chdir(tempDir)
95
+ await initAgency(tempDir, "test")
96
+
97
+ // Create first agency branch
98
+ await runTestEffect(
99
+ task({
100
+ silent: true,
101
+ branch: "first-task",
102
+ }),
103
+ )
104
+
105
+ // Add a unique file to identify this branch
106
+ await createFile(tempDir, "first-task.txt", "first")
107
+ await runGitCommand(tempDir, ["git", "add", "."])
108
+ await runGitCommand(tempDir, [
109
+ "git",
110
+ "commit",
111
+ "-m",
112
+ "Add first task file",
113
+ ])
114
+
115
+ // Emit the branch
116
+ await runTestEffect(emit({ silent: true }))
117
+
118
+ // Go back to main
119
+ await runGitCommand(tempDir, ["git", "checkout", "main"])
120
+
121
+ // Create second task from first agency branch
122
+ await runTestEffect(
123
+ task({
124
+ silent: true,
125
+ branch: "second-task",
126
+ from: "agency/first-task",
127
+ }),
128
+ )
129
+
130
+ const currentBranch = await getCurrentBranch(tempDir)
131
+ expect(currentBranch).toBe("agency/second-task")
132
+
133
+ // Verify first-task.txt exists (came from emit branch)
134
+ const firstTaskFile = await Bun.file(
135
+ join(tempDir, "first-task.txt"),
136
+ ).text()
137
+ expect(firstTaskFile).toBe("first")
138
+
139
+ // Verify TASK.md does NOT exist from first-task
140
+ // (because we branched from emit branch, not source branch)
141
+ const taskMdExists = await Bun.file(join(tempDir, "TASK.md")).exists()
142
+ // The new TASK.md should exist (created by second task)
143
+ // but it should be fresh, not from first-task
144
+ expect(taskMdExists).toBe(true)
145
+ })
146
+
147
+ test("throws error if agency source branch has no emit branch", async () => {
148
+ await initGitRepo(tempDir)
149
+ process.chdir(tempDir)
150
+ await initAgency(tempDir, "test")
151
+
152
+ // Create agency branch without emitting
153
+ await runTestEffect(
154
+ task({
155
+ silent: true,
156
+ branch: "unemitted-task",
157
+ }),
158
+ )
159
+
160
+ // Go back to main
161
+ await runGitCommand(tempDir, ["git", "checkout", "main"])
162
+
163
+ // Try to create task from unemitted agency branch
164
+ await expect(
165
+ runTestEffect(
166
+ task({
167
+ silent: true,
168
+ branch: "second-task",
169
+ from: "agency/unemitted-task",
170
+ }),
171
+ ),
172
+ ).rejects.toThrow("emit branch")
173
+ })
174
+ })
175
+
176
+ describe("--from-current flag", () => {
177
+ test("branches from current branch when it's not an agency branch", async () => {
178
+ await initGitRepo(tempDir)
179
+ process.chdir(tempDir)
180
+ await initAgency(tempDir, "test")
181
+
182
+ // Create and switch to a feature branch
183
+ await runGitCommand(tempDir, ["git", "checkout", "-b", "feature-current"])
184
+ await createFile(tempDir, "current.txt", "content")
185
+ await runGitCommand(tempDir, ["git", "add", "."])
186
+ await runGitCommand(tempDir, ["git", "commit", "-m", "Add current file"])
187
+
188
+ // Go back to main to create the task
189
+ await runGitCommand(tempDir, ["git", "checkout", "main"])
190
+
191
+ // Create task from feature-current branch
192
+ await runTestEffect(
193
+ task({
194
+ silent: true,
195
+ branch: "my-task",
196
+ from: "feature-current",
197
+ }),
198
+ )
199
+
200
+ const currentBranch = await getCurrentBranch(tempDir)
201
+ expect(currentBranch).toBe("agency/my-task")
202
+
203
+ // Verify current.txt exists
204
+ const currentFile = await Bun.file(join(tempDir, "current.txt")).text()
205
+ expect(currentFile).toBe("content")
206
+ })
207
+
208
+ test("uses emit branch when --from specifies an agency branch", async () => {
209
+ await initGitRepo(tempDir)
210
+ process.chdir(tempDir)
211
+ await initAgency(tempDir, "test")
212
+
213
+ // Create first agency branch
214
+ await runTestEffect(
215
+ task({
216
+ silent: true,
217
+ branch: "first-task",
218
+ }),
219
+ )
220
+
221
+ await createFile(tempDir, "first.txt", "first")
222
+ await runGitCommand(tempDir, ["git", "add", "."])
223
+ await runGitCommand(tempDir, ["git", "commit", "-m", "Add first file"])
224
+
225
+ // Emit the branch
226
+ await runTestEffect(emit({ silent: true }))
227
+
228
+ // Go back to main
229
+ await runGitCommand(tempDir, ["git", "checkout", "main"])
230
+
231
+ // Create task from first agency branch
232
+ await runTestEffect(
233
+ task({
234
+ silent: true,
235
+ branch: "second-task",
236
+ from: "agency/first-task",
237
+ }),
238
+ )
239
+
240
+ const currentBranch = await getCurrentBranch(tempDir)
241
+ expect(currentBranch).toBe("agency/second-task")
242
+
243
+ // Verify first.txt exists
244
+ const firstFile = await Bun.file(join(tempDir, "first.txt")).text()
245
+ expect(firstFile).toBe("first")
246
+ })
247
+
248
+ test("throws error if --from agency branch has no emit branch", async () => {
249
+ await initGitRepo(tempDir)
250
+ process.chdir(tempDir)
251
+ await initAgency(tempDir, "test")
252
+
253
+ // Create agency branch without emitting
254
+ await runTestEffect(
255
+ task({
256
+ silent: true,
257
+ branch: "unemitted-task",
258
+ }),
259
+ )
260
+
261
+ // Go back to main
262
+ await runGitCommand(tempDir, ["git", "checkout", "main"])
263
+
264
+ // Try to create task from unemitted agency branch
265
+ await expect(
266
+ runTestEffect(
267
+ task({
268
+ silent: true,
269
+ branch: "second-task",
270
+ from: "agency/unemitted-task",
271
+ }),
272
+ ),
273
+ ).rejects.toThrow("emit branch")
274
+ })
275
+ })
276
+
277
+ describe("--from and --from-current validation", () => {
278
+ test("throws error if both flags are used", async () => {
279
+ await initGitRepo(tempDir)
280
+ process.chdir(tempDir)
281
+ await initAgency(tempDir, "test")
282
+
283
+ await expect(
284
+ runTestEffect(
285
+ task({
286
+ silent: true,
287
+ branch: "my-task",
288
+ from: "main",
289
+ fromCurrent: true,
290
+ }),
291
+ ),
292
+ ).rejects.toThrow("Cannot use both --from and --from-current")
293
+ })
294
+ })
295
+
296
+ describe("default branching behavior", () => {
297
+ test("branches from auto-detected main branch by default", async () => {
298
+ await initGitRepo(tempDir)
299
+ process.chdir(tempDir)
300
+ await initAgency(tempDir, "test")
301
+
302
+ // Create a commit on main
303
+ await createFile(tempDir, "main.txt", "main content")
304
+ await runGitCommand(tempDir, ["git", "add", "."])
305
+ await runGitCommand(tempDir, ["git", "commit", "-m", "Main commit"])
306
+
307
+ // Create a different branch and switch to it
308
+ await runGitCommand(tempDir, ["git", "checkout", "-b", "other-branch"])
309
+ await createFile(tempDir, "other.txt", "other content")
310
+ await runGitCommand(tempDir, ["git", "add", "."])
311
+ await runGitCommand(tempDir, ["git", "commit", "-m", "Other commit"])
312
+
313
+ // Go back to main to set it as main branch
314
+ await runGitCommand(tempDir, ["git", "checkout", "main"])
315
+
316
+ // Create task (should branch from main)
317
+ await runTestEffect(
318
+ task({
319
+ silent: true,
320
+ branch: "my-task",
321
+ }),
322
+ )
323
+
324
+ const currentBranch = await getCurrentBranch(tempDir)
325
+ expect(currentBranch).toBe("agency/my-task")
326
+
327
+ // Verify main.txt exists but other.txt does not
328
+ const mainExists = await Bun.file(join(tempDir, "main.txt")).exists()
329
+ const otherExists = await Bun.file(join(tempDir, "other.txt")).exists()
330
+ expect(mainExists).toBe(true)
331
+ expect(otherExists).toBe(false)
332
+ })
333
+ })
334
+ })
@@ -0,0 +1,141 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { taskEdit, task } from "./task"
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ initGitRepo,
8
+ initAgency,
9
+ readFile,
10
+ runTestEffect,
11
+ } from "../test-utils"
12
+
13
+ describe("edit command", () => {
14
+ let tempDir: string
15
+ let originalCwd: string
16
+ let originalConfigDir: string | undefined
17
+ let originalEditor: string | undefined
18
+
19
+ beforeEach(async () => {
20
+ tempDir = await createTempDir()
21
+ originalCwd = process.cwd()
22
+ originalConfigDir = process.env.AGENCY_CONFIG_DIR
23
+ originalEditor = process.env.EDITOR
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 (originalEditor !== undefined) {
36
+ process.env.EDITOR = originalEditor
37
+ } else {
38
+ delete process.env.EDITOR
39
+ }
40
+ if (
41
+ process.env.AGENCY_CONFIG_DIR &&
42
+ process.env.AGENCY_CONFIG_DIR !== originalConfigDir
43
+ ) {
44
+ await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
45
+ }
46
+ await cleanupTempDir(tempDir)
47
+ })
48
+
49
+ test("throws error when not in git repo", async () => {
50
+ process.chdir(tempDir)
51
+
52
+ await expect(runTestEffect(taskEdit({ silent: true }))).rejects.toThrow(
53
+ "Not in a git repository",
54
+ )
55
+ })
56
+
57
+ test("throws error when TASK.md does not exist", async () => {
58
+ await initGitRepo(tempDir)
59
+ process.chdir(tempDir)
60
+
61
+ await expect(runTestEffect(taskEdit({ silent: true }))).rejects.toThrow(
62
+ "TASK.md not found in repository root",
63
+ )
64
+ })
65
+
66
+ test("opens TASK.md in editor when it exists", async () => {
67
+ await initGitRepo(tempDir)
68
+ process.chdir(tempDir)
69
+
70
+ // Initialize to create TASK.md
71
+ await initAgency(tempDir, "test-task")
72
+
73
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
74
+
75
+ // Use a mock editor that just exits successfully
76
+ process.env.EDITOR = "true" // 'true' is a command that always exits with code 0
77
+
78
+ // Should not throw
79
+ await expect(
80
+ runTestEffect(taskEdit({ silent: true })),
81
+ ).resolves.toBeUndefined()
82
+ })
83
+
84
+ test("uses EDITOR environment variable", async () => {
85
+ await initGitRepo(tempDir)
86
+ process.chdir(tempDir)
87
+
88
+ // Initialize to create TASK.md
89
+ await initAgency(tempDir, "test-task")
90
+
91
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
92
+
93
+ // Use 'true' command which exits successfully without doing anything
94
+ process.env.EDITOR = "true"
95
+
96
+ // Should complete without error
97
+ await expect(
98
+ runTestEffect(taskEdit({ silent: true })),
99
+ ).resolves.toBeUndefined()
100
+ })
101
+
102
+ test("uses VISUAL environment variable over EDITOR", async () => {
103
+ await initGitRepo(tempDir)
104
+ process.chdir(tempDir)
105
+
106
+ // Initialize to create TASK.md
107
+ await initAgency(tempDir, "test-task")
108
+
109
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
110
+
111
+ // Set VISUAL to 'true' and EDITOR to 'false'
112
+ // If VISUAL is used (correct), it should succeed
113
+ // If EDITOR is used (incorrect), it should fail
114
+ process.env.VISUAL = "true"
115
+ process.env.EDITOR = "false"
116
+
117
+ // Should complete without error, proving VISUAL was used
118
+ await expect(
119
+ runTestEffect(taskEdit({ silent: true })),
120
+ ).resolves.toBeUndefined()
121
+ })
122
+
123
+ test("throws error when editor exits with non-zero code", async () => {
124
+ await initGitRepo(tempDir)
125
+ process.chdir(tempDir)
126
+
127
+ // Initialize to create TASK.md
128
+ await initAgency(tempDir, "test-task")
129
+
130
+ await runTestEffect(task({ silent: true, branch: "test-feature" }))
131
+
132
+ // Clear VISUAL to ensure EDITOR is used
133
+ delete process.env.VISUAL
134
+ // Use a mock editor that fails
135
+ process.env.EDITOR = "false" // 'false' is a command that always exits with code 1
136
+
137
+ await expect(runTestEffect(taskEdit({ silent: true }))).rejects.toThrow(
138
+ "Editor exited with code",
139
+ )
140
+ })
141
+ })