@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,140 @@
1
+ import { basename } from "path"
2
+ import { Effect } from "effect"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { GitService } from "../services/GitService"
5
+ import { PromptService } from "../services/PromptService"
6
+ import { TemplateService } from "../services/TemplateService"
7
+ import highlight, { done } from "../utils/colors"
8
+ import { createLoggers, ensureGitRepo, getTemplateName } from "../utils/effect"
9
+
10
+ interface InitOptions extends BaseCommandOptions {
11
+ template?: string
12
+ }
13
+
14
+ export const init = (options: InitOptions = {}) =>
15
+ Effect.gen(function* () {
16
+ const { silent = false, cwd } = options
17
+ const { log, verboseLog } = createLoggers(options)
18
+
19
+ const git = yield* GitService
20
+ const promptService = yield* PromptService
21
+ const templateService = yield* TemplateService
22
+
23
+ const gitRoot = yield* ensureGitRepo(cwd)
24
+
25
+ // Check if already initialized
26
+ const existingTemplate = yield* getTemplateName(gitRoot)
27
+ if (existingTemplate && !options.template) {
28
+ return yield* Effect.fail(
29
+ new Error(
30
+ `Already initialized with template ${highlight.template(existingTemplate)}.\n` +
31
+ `To change template, run: agency init --template <name>`,
32
+ ),
33
+ )
34
+ }
35
+
36
+ let templateName = options.template
37
+
38
+ // If template name not provided, show interactive selection
39
+ if (!templateName) {
40
+ if (silent) {
41
+ return yield* Effect.fail(
42
+ new Error(
43
+ "Template name required. Use --template flag in silent mode.",
44
+ ),
45
+ )
46
+ }
47
+
48
+ const existingTemplates = yield* templateService.listTemplates()
49
+ verboseLog(`Found ${existingTemplates.length} existing templates`)
50
+
51
+ // Get current directory name as default suggestion
52
+ let defaultTemplateName: string | undefined
53
+ const dirName = basename(gitRoot)
54
+ const sanitizedDirName =
55
+ yield* promptService.sanitizeTemplateName(dirName)
56
+
57
+ if (sanitizedDirName && !existingTemplates.includes(sanitizedDirName)) {
58
+ defaultTemplateName = sanitizedDirName
59
+ verboseLog(`Suggesting default template name: ${defaultTemplateName}`)
60
+ }
61
+
62
+ const selectedName = yield* promptService.promptForTemplate(
63
+ existingTemplates,
64
+ {
65
+ defaultValue: defaultTemplateName,
66
+ allowNew: true,
67
+ },
68
+ )
69
+
70
+ // If selected from list, use as-is; otherwise sanitize new name
71
+ if (existingTemplates.includes(selectedName)) {
72
+ templateName = selectedName
73
+ } else {
74
+ templateName = yield* promptService.sanitizeTemplateName(selectedName)
75
+ }
76
+
77
+ verboseLog(`Selected template: ${templateName}`)
78
+ }
79
+
80
+ if (!templateName) {
81
+ return yield* Effect.fail(new Error("Template name is required."))
82
+ }
83
+
84
+ // Resolve and save default remote (with smart precedence)
85
+ const remote = yield* git
86
+ .resolveRemote(gitRoot)
87
+ .pipe(Effect.catchAll(() => Effect.succeed(null)))
88
+
89
+ if (remote) {
90
+ yield* git.setRemoteConfig(remote, gitRoot)
91
+ verboseLog(`Detected and saved remote: ${remote}`)
92
+ } else {
93
+ verboseLog("No remote detected - skip remote configuration")
94
+ }
95
+
96
+ // Save template name to git config
97
+ yield* git.setGitConfig("agency.template", templateName, gitRoot)
98
+ log(
99
+ done(
100
+ `Initialized with template ${highlight.template(templateName)}${existingTemplate && existingTemplate !== templateName ? ` (was ${highlight.template(existingTemplate)})` : ""}`,
101
+ ),
102
+ )
103
+
104
+ // Note: We do NOT create the template directory here
105
+ // It will be created when the user runs 'agency template save'
106
+ verboseLog(
107
+ `Template directory will be created when you save files with 'agency template save'`,
108
+ )
109
+ })
110
+
111
+ export const help = `
112
+ Usage: agency init [options]
113
+
114
+ Initialize agency for this repository by selecting a template.
115
+
116
+ This command must be run before using other agency commands like 'agency task'.
117
+ It saves your template selection to .git/config (not committed to the repository).
118
+
119
+ On first run, you'll be prompted to either:
120
+ - Select an existing template from your ~/.config/agency/templates/
121
+ - Create a new template by entering a new name
122
+
123
+ If no templates exist, the default suggestion is the current directory name.
124
+
125
+ The template directory itself is only created when you actually save files to it
126
+ using 'agency template save'.
127
+
128
+ Options:
129
+ -t, --template Specify template name (skips interactive prompt)
130
+
131
+ Examples:
132
+ agency init # Interactive template selection
133
+ agency init --template work # Set template to 'work'
134
+
135
+ Notes:
136
+ - Template name is saved to .git/config (agency.template)
137
+ - Template directory is NOT created until 'agency template save' is used
138
+ - To change template later, run 'agency init --template <name>'
139
+ - Run 'agency task' after initialization to create template files
140
+ `
@@ -0,0 +1,365 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { merge } from "./merge"
4
+ import { emit } from "./emit"
5
+ import { task } from "./task"
6
+ import {
7
+ createTempDir,
8
+ cleanupTempDir,
9
+ initGitRepo,
10
+ initAgency,
11
+ getGitOutput,
12
+ getCurrentBranch,
13
+ createCommit,
14
+ branchExists,
15
+ checkoutBranch,
16
+ createBranch,
17
+ addAndCommit,
18
+ setupRemote,
19
+ deleteBranch,
20
+ runTestEffect,
21
+ } from "../test-utils"
22
+
23
+ // Cache the git-filter-repo availability check (it doesn't change during test run)
24
+ let hasGitFilterRepoCache: boolean | null = null
25
+ async function checkGitFilterRepo(): Promise<boolean> {
26
+ if (hasGitFilterRepoCache === null) {
27
+ const proc = Bun.spawn(["which", "git-filter-repo"], {
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ })
31
+ await proc.exited
32
+ hasGitFilterRepoCache = proc.exitCode === 0
33
+ }
34
+ return hasGitFilterRepoCache
35
+ }
36
+
37
+ describe("merge command", () => {
38
+ let tempDir: string
39
+ let originalCwd: string
40
+ let hasGitFilterRepo: boolean
41
+
42
+ beforeEach(async () => {
43
+ tempDir = await createTempDir()
44
+ originalCwd = process.cwd()
45
+ process.chdir(tempDir)
46
+
47
+ // Set config path to non-existent file to use defaults
48
+ process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
49
+ // Set config dir to temp dir to avoid picking up user's config files
50
+ process.env.AGENCY_CONFIG_DIR = await createTempDir()
51
+
52
+ // Check if git-filter-repo is available (cached)
53
+ hasGitFilterRepo = await checkGitFilterRepo()
54
+
55
+ // Initialize git repo with main branch (already includes initial commit)
56
+ await initGitRepo(tempDir)
57
+
58
+ // Set up origin for git-filter-repo
59
+ await setupRemote(tempDir, "origin", tempDir)
60
+
61
+ // Create a source branch (with agency/ prefix per new default config)
62
+ await createBranch(tempDir, "agency/feature")
63
+
64
+ // Initialize AGENTS.md on feature branch
65
+ await initAgency(tempDir, "test")
66
+
67
+ await runTestEffect(task({ silent: true }))
68
+
69
+ // Ensure agency.json has baseBranch set (task should auto-detect it, but ensure it's there)
70
+ const agencyJsonPath = join(tempDir, "agency.json")
71
+ const agencyJson = await Bun.file(agencyJsonPath).json()
72
+ if (!agencyJson.baseBranch) {
73
+ agencyJson.baseBranch = "origin/main"
74
+ await Bun.write(
75
+ agencyJsonPath,
76
+ JSON.stringify(agencyJson, null, 2) + "\n",
77
+ )
78
+ }
79
+
80
+ await addAndCommit(tempDir, "AGENTS.md agency.json", "Add AGENTS.md")
81
+
82
+ // Create another commit on feature branch
83
+ await createCommit(tempDir, "Feature work")
84
+ })
85
+
86
+ afterEach(async () => {
87
+ process.chdir(originalCwd)
88
+ delete process.env.AGENCY_CONFIG_PATH
89
+ if (process.env.AGENCY_CONFIG_DIR) {
90
+ await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
91
+ delete process.env.AGENCY_CONFIG_DIR
92
+ }
93
+ await cleanupTempDir(tempDir)
94
+ })
95
+
96
+ describe("merge from source branch", () => {
97
+ test("creates emit branch and merges when run from source branch", async () => {
98
+ if (!hasGitFilterRepo) {
99
+ console.log("Skipping test: git-filter-repo not installed")
100
+ return
101
+ }
102
+
103
+ // We're on feature branch (source)
104
+ const currentBranch = await getCurrentBranch(tempDir)
105
+ expect(currentBranch).toBe("agency/feature")
106
+
107
+ // Run merge - should create feature--PR and merge it to main
108
+ await runTestEffect(merge({ silent: true }))
109
+
110
+ // Should be on main branch after merge
111
+ const afterMergeBranch = await getCurrentBranch(tempDir)
112
+ expect(afterMergeBranch).toBe("main")
113
+
114
+ // emit branch should exist
115
+ const prExists = await branchExists(tempDir, "feature")
116
+ expect(prExists).toBe(true)
117
+
118
+ // Main should have the feature work but not AGENTS.md
119
+ const files = await getGitOutput(tempDir, ["ls-files"])
120
+ expect(files).not.toContain("AGENTS.md")
121
+ })
122
+
123
+ test("recreates emit branch if it already exists", async () => {
124
+ if (!hasGitFilterRepo) {
125
+ console.log("Skipping test: git-filter-repo not installed")
126
+ return
127
+ }
128
+
129
+ // Create emit branch first
130
+ await runTestEffect(emit({ silent: true }))
131
+
132
+ // Go back to feature branch
133
+ await checkoutBranch(tempDir, "agency/feature")
134
+
135
+ // Make additional changes
136
+ await createCommit(tempDir, "More feature work")
137
+
138
+ // Run merge - should recreate emit branch with new changes
139
+ await runTestEffect(merge({ silent: true }))
140
+
141
+ // Should be on main after merge
142
+ const currentBranch = await getCurrentBranch(tempDir)
143
+ expect(currentBranch).toBe("main")
144
+ })
145
+ })
146
+
147
+ describe("merge from emit branch", () => {
148
+ test("merges emit branch directly when run from emit branch", async () => {
149
+ if (!hasGitFilterRepo) {
150
+ console.log("Skipping test: git-filter-repo not installed")
151
+ return
152
+ }
153
+
154
+ // Create emit branch
155
+ await runTestEffect(emit({ silent: true }))
156
+
157
+ // emit() now stays on source branch, so we need to checkout to emit branch
158
+ await checkoutBranch(tempDir, "feature")
159
+
160
+ // We're on feature--PR now
161
+ const currentBranch = await getCurrentBranch(tempDir)
162
+ expect(currentBranch).toBe("feature")
163
+
164
+ // Run merge - should merge feature--PR to main
165
+ await runTestEffect(merge({ silent: true }))
166
+
167
+ // Should be on main after merge
168
+ const afterMergeBranch = await getCurrentBranch(tempDir)
169
+ expect(afterMergeBranch).toBe("main")
170
+
171
+ // Main should have the feature work but not AGENTS.md
172
+ const files = await getGitOutput(tempDir, ["ls-files"])
173
+ expect(files).not.toContain("AGENTS.md")
174
+ })
175
+
176
+ test("throws error if emit branch has no corresponding source branch", async () => {
177
+ if (!hasGitFilterRepo) {
178
+ console.log("Skipping test: git-filter-repo not installed")
179
+ return
180
+ }
181
+
182
+ // Create emit branch
183
+ await runTestEffect(emit({ silent: true }))
184
+
185
+ // pr() now stays on source branch, so checkout to emit branch
186
+ await checkoutBranch(tempDir, "feature")
187
+
188
+ // Delete the source branch
189
+ await deleteBranch(tempDir, "agency/feature", true)
190
+
191
+ // Try to merge - should fail (error message may vary since source branch is deleted)
192
+ await expect(runTestEffect(merge({ silent: true }))).rejects.toThrow()
193
+ })
194
+ })
195
+
196
+ describe("error handling", () => {
197
+ test("throws error when not in a git repository", async () => {
198
+ const nonGitDir = await createTempDir()
199
+ process.chdir(nonGitDir)
200
+
201
+ await expect(runTestEffect(merge({ silent: true }))).rejects.toThrow(
202
+ "Not in a git repository",
203
+ )
204
+
205
+ await cleanupTempDir(nonGitDir)
206
+ })
207
+
208
+ test("throws error if base branch does not exist locally", async () => {
209
+ // Create emit branch (skipFilter for speed since we're testing error handling)
210
+ await runTestEffect(emit({ silent: true, skipFilter: true }))
211
+
212
+ // Delete main branch (the base)
213
+ await checkoutBranch(tempDir, "feature")
214
+ await deleteBranch(tempDir, "main", true)
215
+
216
+ // Try to merge - should fail
217
+ await expect(
218
+ runTestEffect(merge({ silent: true, skipFilter: true })),
219
+ ).rejects.toThrow("does not exist locally")
220
+ })
221
+ })
222
+
223
+ describe("silent mode", () => {
224
+ test("silent flag suppresses output", async () => {
225
+ const logs: string[] = []
226
+ const originalLog = console.log
227
+ console.log = (...args: any[]) => logs.push(args.join(" "))
228
+
229
+ await runTestEffect(merge({ silent: true, skipFilter: true }))
230
+
231
+ console.log = originalLog
232
+
233
+ expect(logs.length).toBe(0)
234
+ })
235
+ })
236
+
237
+ describe("squash merge", () => {
238
+ test("performs squash merge when --squash flag is set", async () => {
239
+ // We're on feature branch (source)
240
+ const currentBranch = await getCurrentBranch(tempDir)
241
+ expect(currentBranch).toBe("agency/feature")
242
+
243
+ // Run merge with squash flag (skipFilter for speed, we're testing squash behavior)
244
+ await runTestEffect(
245
+ merge({ silent: true, squash: true, skipFilter: true }),
246
+ )
247
+
248
+ // Should be on main branch after merge
249
+ const afterMergeBranch = await getCurrentBranch(tempDir)
250
+ expect(afterMergeBranch).toBe("main")
251
+
252
+ // Check that changes are staged but not committed
253
+ const status = await getGitOutput(tempDir, ["status", "--porcelain"])
254
+
255
+ // Staged changes should be present (indicated by status codes in first column)
256
+ expect(status.trim().length).toBeGreaterThan(0)
257
+
258
+ // Get the log to verify no merge commit was created
259
+ const log = await getGitOutput(tempDir, ["log", "--oneline", "-5"])
260
+
261
+ // Should not contain a merge commit message
262
+ expect(log).not.toContain("Merge branch")
263
+ })
264
+
265
+ test("performs regular merge when --squash flag is not set", async () => {
266
+ // We're on feature branch (source)
267
+ const currentBranch = await getCurrentBranch(tempDir)
268
+ expect(currentBranch).toBe("agency/feature")
269
+
270
+ // Run merge without squash flag (skipFilter for speed, we're testing merge behavior)
271
+ await runTestEffect(
272
+ merge({ silent: true, squash: false, skipFilter: true }),
273
+ )
274
+
275
+ // Should be on main branch after merge
276
+ const afterMergeBranch = await getCurrentBranch(tempDir)
277
+ expect(afterMergeBranch).toBe("main")
278
+
279
+ // With regular merge (not squash), there should be no staged changes
280
+ const status = await getGitOutput(tempDir, ["status", "--porcelain"])
281
+
282
+ // No staged changes - everything should be committed
283
+ expect(status.trim().length).toBe(0)
284
+
285
+ // Get the log to verify commits were included
286
+ const log = await getGitOutput(tempDir, ["log", "--oneline", "-5"])
287
+
288
+ // Should contain the feature work commit (regular merge includes all commits)
289
+ expect(log).toContain("Feature work")
290
+ })
291
+ })
292
+
293
+ describe("push flag", () => {
294
+ test("pushes base branch to origin when --push flag is set", async () => {
295
+ // We're on feature branch (source)
296
+ const currentBranch = await getCurrentBranch(tempDir)
297
+ expect(currentBranch).toBe("agency/feature")
298
+
299
+ // Get the current commit on main before merge
300
+ await checkoutBranch(tempDir, "main")
301
+ const beforeCommit = (
302
+ await getGitOutput(tempDir, ["rev-parse", "HEAD"])
303
+ ).trim()
304
+
305
+ // Go back to feature branch
306
+ await checkoutBranch(tempDir, "agency/feature")
307
+
308
+ // Run merge with push flag (skipFilter for speed, we're testing push behavior)
309
+ await runTestEffect(merge({ silent: true, push: true, skipFilter: true }))
310
+
311
+ // Should be on main branch after merge
312
+ const afterMergeBranch = await getCurrentBranch(tempDir)
313
+ expect(afterMergeBranch).toBe("main")
314
+
315
+ // Get the current commit on main after merge
316
+ const afterCommit = (
317
+ await getGitOutput(tempDir, ["rev-parse", "HEAD"])
318
+ ).trim()
319
+
320
+ // The commit should have changed (merge happened)
321
+ expect(afterCommit).not.toBe(beforeCommit)
322
+
323
+ // Verify that origin/main points to the same commit as local main
324
+ const originMainCommit = (
325
+ await getGitOutput(tempDir, ["rev-parse", "origin/main"])
326
+ ).trim()
327
+
328
+ // origin/main should be at the same commit as local main (push succeeded)
329
+ expect(originMainCommit).toBe(afterCommit)
330
+ })
331
+
332
+ test("does not push when --push flag is not set", async () => {
333
+ // We're on feature branch (source)
334
+ const currentBranch = await getCurrentBranch(tempDir)
335
+ expect(currentBranch).toBe("agency/feature")
336
+
337
+ // Get the current commit on origin/main before merge
338
+ const beforeOriginCommit = (
339
+ await getGitOutput(tempDir, ["rev-parse", "origin/main"])
340
+ ).trim()
341
+
342
+ // Run merge without push flag (skipFilter for speed, we're testing push behavior)
343
+ await runTestEffect(merge({ silent: true, skipFilter: true }))
344
+
345
+ // Should be on main branch after merge
346
+ const afterMergeBranch = await getCurrentBranch(tempDir)
347
+ expect(afterMergeBranch).toBe("main")
348
+
349
+ // Get the current commit on origin/main after merge
350
+ const afterOriginCommit = (
351
+ await getGitOutput(tempDir, ["rev-parse", "origin/main"])
352
+ ).trim()
353
+
354
+ // origin/main should still be at the same commit (no push happened)
355
+ expect(afterOriginCommit).toBe(beforeOriginCommit)
356
+
357
+ // But local main should have moved forward
358
+ const localMainCommit = (
359
+ await getGitOutput(tempDir, ["rev-parse", "HEAD"])
360
+ ).trim()
361
+
362
+ expect(localMainCommit).not.toBe(beforeOriginCommit)
363
+ })
364
+ })
365
+ })