@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,258 @@
1
+ import { Effect, DateTime } from "effect"
2
+ import { join } from "node:path"
3
+ import { Schema } from "@effect/schema"
4
+ import type { BaseCommandOptions } from "../utils/command"
5
+ import { GitService } from "../services/GitService"
6
+ import { ConfigService } from "../services/ConfigService"
7
+ import { FileSystemService } from "../services/FileSystemService"
8
+ import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
9
+ import { AgencyMetadata } from "../schemas"
10
+ import highlight, { plural } from "../utils/colors"
11
+ import { createLoggers, ensureGitRepo, getTemplateName } from "../utils/effect"
12
+
13
+ interface StatusOptions extends BaseCommandOptions {
14
+ json?: boolean
15
+ }
16
+
17
+ /**
18
+ * Branch type for status output
19
+ */
20
+ type BranchType = "source" | "emit" | "neither"
21
+
22
+ /**
23
+ * Base files that are always in the backpack
24
+ */
25
+ const BASE_BACKPACK_FILES = ["TASK.md", "AGENCY.md", "agency.json"]
26
+
27
+ /**
28
+ * Status data structure returned by the status command
29
+ */
30
+ interface StatusData {
31
+ initialized: boolean
32
+ branchType: BranchType
33
+ currentBranch: string
34
+ sourceBranch: string | null
35
+ emitBranch: string | null
36
+ correspondingBranchExists: boolean
37
+ template: string | null
38
+ managedFiles: string[]
39
+ baseBranch: string | null
40
+ createdAt: string | null
41
+ }
42
+
43
+ /**
44
+ * Read agency.json metadata from the current working directory.
45
+ */
46
+ const readAgencyMetadataFromDisk = (gitRoot: string) =>
47
+ Effect.gen(function* () {
48
+ const fs = yield* FileSystemService
49
+ const metadataPath = join(gitRoot, "agency.json")
50
+
51
+ const exists = yield* fs.exists(metadataPath)
52
+ if (!exists) {
53
+ return null
54
+ }
55
+
56
+ const content = yield* fs.readFile(metadataPath)
57
+ return yield* parseAgencyMetadata(content)
58
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)))
59
+
60
+ /**
61
+ * Read agency.json metadata from a specific branch using git show.
62
+ */
63
+ const readAgencyMetadataFromBranch = (gitRoot: string, branch: string) =>
64
+ Effect.gen(function* () {
65
+ const git = yield* GitService
66
+
67
+ // Try to read agency.json from the branch using git show
68
+ const result = yield* git.runGitCommand(
69
+ ["git", "show", `${branch}:agency.json`],
70
+ gitRoot,
71
+ { captureOutput: true },
72
+ )
73
+
74
+ if (result.exitCode !== 0 || !result.stdout) {
75
+ return null
76
+ }
77
+
78
+ return yield* parseAgencyMetadata(result.stdout)
79
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)))
80
+
81
+ /**
82
+ * Parse and validate agency.json content.
83
+ */
84
+ const parseAgencyMetadata = (content: string) =>
85
+ Effect.gen(function* () {
86
+ const data = yield* Effect.try({
87
+ try: () => JSON.parse(content),
88
+ catch: () => new Error("Failed to parse agency.json"),
89
+ })
90
+
91
+ // Validate version
92
+ if (typeof data.version !== "number" || data.version !== 1) {
93
+ return null
94
+ }
95
+
96
+ // Parse and validate using Effect schema
97
+ const metadata = yield* Effect.try({
98
+ try: () => Schema.decodeUnknownSync(AgencyMetadata)(data),
99
+ catch: () => new Error("Invalid agency.json format"),
100
+ })
101
+
102
+ return metadata
103
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)))
104
+
105
+ export const status = (options: StatusOptions = {}) =>
106
+ Effect.gen(function* () {
107
+ const { json = false } = options
108
+ const { log } = createLoggers(options)
109
+
110
+ const git = yield* GitService
111
+ const configService = yield* ConfigService
112
+
113
+ const gitRoot = yield* ensureGitRepo()
114
+
115
+ // Load config for emit branch pattern
116
+ const config = yield* configService.loadConfig()
117
+
118
+ // Get current branch
119
+ const currentBranch = yield* git.getCurrentBranch(gitRoot)
120
+
121
+ // Resolve branch pair to determine if we're on source or emit branch
122
+ const branches = yield* resolveBranchPairWithAgencyJson(
123
+ gitRoot,
124
+ currentBranch,
125
+ config.sourceBranchPattern,
126
+ config.emitBranch,
127
+ )
128
+ const { sourceBranch, emitBranch, isOnEmitBranch } = branches
129
+
130
+ // Check if agency is initialized
131
+ // If on emit branch, read agency.json from source branch; otherwise read from disk
132
+ const metadata = isOnEmitBranch
133
+ ? yield* readAgencyMetadataFromBranch(gitRoot, sourceBranch)
134
+ : yield* readAgencyMetadataFromDisk(gitRoot)
135
+ const initialized = metadata !== null
136
+
137
+ // Determine branch type
138
+ let branchType: BranchType = "neither"
139
+ if (initialized) {
140
+ branchType = isOnEmitBranch ? "emit" : "source"
141
+ }
142
+
143
+ // Check if the corresponding branch exists
144
+ const correspondingBranch = isOnEmitBranch ? sourceBranch : emitBranch
145
+ const correspondingBranchExists = yield* git.branchExists(
146
+ gitRoot,
147
+ correspondingBranch,
148
+ )
149
+
150
+ // Get template name from git config
151
+ const template = yield* getTemplateName(gitRoot)
152
+
153
+ // Build the complete backpack (all files carried in/out)
154
+ const backpackFiles = metadata
155
+ ? [...BASE_BACKPACK_FILES, ...metadata.injectedFiles]
156
+ : []
157
+
158
+ // Build status data
159
+ const statusData: StatusData = {
160
+ initialized,
161
+ branchType,
162
+ currentBranch,
163
+ sourceBranch: isOnEmitBranch ? sourceBranch : currentBranch,
164
+ emitBranch: isOnEmitBranch ? currentBranch : emitBranch,
165
+ correspondingBranchExists,
166
+ template,
167
+ managedFiles: backpackFiles,
168
+ baseBranch: metadata?.baseBranch ?? null,
169
+ createdAt: metadata?.createdAt
170
+ ? DateTime.toDateUtc(metadata.createdAt).toISOString()
171
+ : null,
172
+ }
173
+
174
+ if (json) {
175
+ // Output JSON format
176
+ log(JSON.stringify(statusData, null, 2))
177
+ } else {
178
+ // Output human-readable format with highlighting
179
+ log("")
180
+
181
+ if (!initialized) {
182
+ // Not initialized - show minimal info
183
+ log(
184
+ `Not initialized (run ${highlight.value("agency task")} to initialize)`,
185
+ )
186
+ log(`Current branch: ${highlight.branch(currentBranch)}`)
187
+ if (template) {
188
+ log(`Template: ${highlight.template(template)}`)
189
+ }
190
+ } else {
191
+ // Initialized - show full status
192
+ log(`Current branch: ${highlight.branch(currentBranch)}`)
193
+
194
+ // Branch type
195
+ const branchTypeDisplay = isOnEmitBranch
196
+ ? "Emit branch"
197
+ : "Source branch"
198
+ log(`Branch type: ${branchTypeDisplay}`)
199
+
200
+ // Show corresponding branch only if it exists
201
+ if (correspondingBranchExists) {
202
+ if (isOnEmitBranch) {
203
+ log(`Source branch: ${highlight.branch(statusData.sourceBranch!)}`)
204
+ } else {
205
+ log(`Emit branch: ${highlight.branch(statusData.emitBranch!)}`)
206
+ }
207
+ }
208
+
209
+ // Template
210
+ if (template) {
211
+ log(`Template: ${highlight.template(template)}`)
212
+ }
213
+
214
+ // Base branch
215
+ if (metadata?.baseBranch) {
216
+ log(`Base branch: ${highlight.branch(metadata.baseBranch)}`)
217
+ }
218
+
219
+ // Backpack (files carried into the job and will leave with)
220
+ if (backpackFiles.length > 0) {
221
+ log(`Backpack:`)
222
+ for (const file of backpackFiles) {
223
+ log(` ${highlight.file(file)}`)
224
+ }
225
+ }
226
+
227
+ // Created at
228
+ if (metadata?.createdAt) {
229
+ const date = DateTime.toDateUtc(metadata.createdAt)
230
+ log(`Created: ${date.toLocaleString()}`)
231
+ }
232
+ }
233
+
234
+ log("")
235
+ }
236
+ })
237
+
238
+ export const help = `
239
+ Usage: agency status [options]
240
+
241
+ Display the current status of the agency setup in this repository.
242
+
243
+ Information shown:
244
+ - Whether agency is initialized (agency.json exists)
245
+ - Current branch and branch type (source, emit, or neither)
246
+ - Source and emit branch names
247
+ - Whether the corresponding branch exists
248
+ - Configured template name
249
+ - Backpack (files carried in and filtered during emit)
250
+ - Base branch and creation timestamp
251
+
252
+ Options:
253
+ --json Output status as JSON for scripting
254
+
255
+ Example:
256
+ agency status # Show human-readable status
257
+ agency status --json # Output as JSON
258
+ `
@@ -0,0 +1,194 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { switchBranch } from "./switch"
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ initGitRepo,
8
+ getGitOutput,
9
+ getCurrentBranch,
10
+ createCommit,
11
+ checkoutBranch,
12
+ runTestEffect,
13
+ } from "../test-utils"
14
+
15
+ async function createBranch(cwd: string, branchName: string): Promise<void> {
16
+ await Bun.spawn(["git", "checkout", "-b", branchName], {
17
+ cwd,
18
+ stdout: "pipe",
19
+ stderr: "pipe",
20
+ }).exited
21
+ }
22
+
23
+ describe("switch command", () => {
24
+ let tempDir: string
25
+ let originalCwd: string
26
+
27
+ beforeEach(async () => {
28
+ tempDir = await createTempDir()
29
+ originalCwd = process.cwd()
30
+ process.chdir(tempDir)
31
+
32
+ // Set config path to non-existent file to use defaults
33
+ process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
34
+
35
+ // Initialize git repo
36
+ await initGitRepo(tempDir)
37
+ await createCommit(tempDir, "Initial commit")
38
+
39
+ // Rename to main if needed
40
+ const currentBranch = await getCurrentBranch(tempDir)
41
+ if (currentBranch === "master") {
42
+ await Bun.spawn(["git", "branch", "-m", "main"], {
43
+ cwd: tempDir,
44
+ stdout: "pipe",
45
+ stderr: "pipe",
46
+ }).exited
47
+ }
48
+ })
49
+
50
+ afterEach(async () => {
51
+ process.chdir(originalCwd)
52
+ delete process.env.AGENCY_CONFIG_PATH
53
+ await cleanupTempDir(tempDir)
54
+ })
55
+
56
+ describe("basic functionality", () => {
57
+ test("switches from emit branch to source branch", async () => {
58
+ // Create source and emit branches (source=agency/main, emit=main)
59
+ await createBranch(tempDir, "agency/main")
60
+ await createCommit(tempDir, "Work on source")
61
+ // Emit branch is just "main" (already exists from setup)
62
+ await checkoutBranch(tempDir, "main")
63
+
64
+ // Run switch command
65
+ await runTestEffect(switchBranch({ silent: true }))
66
+
67
+ // Should be on source branch now
68
+ const currentBranch = await getCurrentBranch(tempDir)
69
+ expect(currentBranch).toBe("agency/main")
70
+ })
71
+
72
+ test("switches from source branch to emit branch", async () => {
73
+ // Create source branch (main becomes the emit branch)
74
+ await createBranch(tempDir, "agency/main")
75
+ await createCommit(tempDir, "Work on source")
76
+
77
+ // We're on agency/main (source), switch to main (emit)
78
+ // Run switch command
79
+ await runTestEffect(switchBranch({ silent: true }))
80
+
81
+ // Should be on emit branch now
82
+ const currentBranch = await getCurrentBranch(tempDir)
83
+ expect(currentBranch).toBe("main")
84
+ })
85
+
86
+ test("toggles back and forth", async () => {
87
+ // Create source and emit branches
88
+ await createBranch(tempDir, "agency/main")
89
+ await createCommit(tempDir, "Work on source")
90
+ await checkoutBranch(tempDir, "main") // Go to emit
91
+
92
+ // Switch to source
93
+ await runTestEffect(switchBranch({ silent: true }))
94
+ expect(await getCurrentBranch(tempDir)).toBe("agency/main")
95
+
96
+ // Switch back to emit
97
+ await runTestEffect(switchBranch({ silent: true }))
98
+ expect(await getCurrentBranch(tempDir)).toBe("main")
99
+
100
+ // And back to source
101
+ await runTestEffect(switchBranch({ silent: true }))
102
+ expect(await getCurrentBranch(tempDir)).toBe("agency/main")
103
+ })
104
+
105
+ test("works with custom emit branch pattern", async () => {
106
+ // Create custom config
107
+ const configPath = join(tempDir, "custom-config.json")
108
+ await Bun.write(
109
+ configPath,
110
+ JSON.stringify({
111
+ sourceBranchPattern: "agency/%branch%",
112
+ emitBranch: "PR/%branch%",
113
+ }),
114
+ )
115
+ process.env.AGENCY_CONFIG_PATH = configPath
116
+
117
+ // Create source branch and its emit branch
118
+ await createBranch(tempDir, "agency/feature")
119
+ await createCommit(tempDir, "Feature work")
120
+ await createBranch(tempDir, "PR/feature")
121
+
122
+ // Switch to source
123
+ await runTestEffect(switchBranch({ silent: true }))
124
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
125
+
126
+ // Switch back to emit
127
+ await runTestEffect(switchBranch({ silent: true }))
128
+ expect(await getCurrentBranch(tempDir)).toBe("PR/feature")
129
+ })
130
+ })
131
+
132
+ describe("error handling", () => {
133
+ test("throws error when emit branch doesn't exist", async () => {
134
+ // Use custom emit pattern so emit branch differs from source
135
+ const configPath = join(tempDir, "custom-config.json")
136
+ await Bun.write(
137
+ configPath,
138
+ JSON.stringify({
139
+ sourceBranchPattern: "agency/%branch%",
140
+ emitBranch: "%branch%--PR",
141
+ }),
142
+ )
143
+ process.env.AGENCY_CONFIG_PATH = configPath
144
+
145
+ // Create source branch, but not the emit branch
146
+ await createBranch(tempDir, "agency/feature")
147
+ // feature--PR doesn't exist
148
+
149
+ await expect(
150
+ runTestEffect(switchBranch({ silent: true })),
151
+ ).rejects.toThrow(/Emit branch .* does not exist/)
152
+ })
153
+
154
+ test("throws error when emit branch doesn't exist", async () => {
155
+ // Create source branch but no emit branch
156
+ await createBranch(tempDir, "agency/feature")
157
+ // We never created 'feature' (emit), so it doesn't exist
158
+
159
+ await expect(
160
+ runTestEffect(switchBranch({ silent: true })),
161
+ ).rejects.toThrow(/Emit branch .* does not exist/)
162
+ })
163
+
164
+ test("throws error when not in a git repository", async () => {
165
+ const nonGitDir = await createTempDir()
166
+ process.chdir(nonGitDir)
167
+
168
+ await expect(
169
+ runTestEffect(switchBranch({ silent: true })),
170
+ ).rejects.toThrow("Not in a git repository")
171
+
172
+ await cleanupTempDir(nonGitDir)
173
+ })
174
+ })
175
+
176
+ describe("silent mode", () => {
177
+ test("silent flag suppresses output", async () => {
178
+ await createBranch(tempDir, "agency/main")
179
+ await checkoutBranch(tempDir, "main")
180
+
181
+ // Capture output
182
+ const originalLog = console.log
183
+ let logCalled = false
184
+ console.log = () => {
185
+ logCalled = true
186
+ }
187
+
188
+ await runTestEffect(switchBranch({ silent: true }))
189
+
190
+ console.log = originalLog
191
+ expect(logCalled).toBe(false)
192
+ })
193
+ })
194
+ })
@@ -0,0 +1,84 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { GitService } from "../services/GitService"
4
+ import { ConfigService } from "../services/ConfigService"
5
+ import {
6
+ resolveBranchPairWithAgencyJson,
7
+ type BranchPair,
8
+ } from "../utils/pr-branch"
9
+ import highlight, { done } from "../utils/colors"
10
+ import {
11
+ createLoggers,
12
+ ensureGitRepo,
13
+ ensureBranchExists,
14
+ } from "../utils/effect"
15
+
16
+ interface SwitchOptions extends BaseCommandOptions {}
17
+
18
+ export const switchBranch = (options: SwitchOptions = {}) =>
19
+ Effect.gen(function* () {
20
+ const { log } = createLoggers(options)
21
+
22
+ const git = yield* GitService
23
+ const configService = yield* ConfigService
24
+
25
+ const gitRoot = yield* ensureGitRepo()
26
+
27
+ // Load config
28
+ const config = yield* configService.loadConfig()
29
+
30
+ // Get current branch and resolve the branch pair
31
+ const currentBranch = yield* git.getCurrentBranch(gitRoot)
32
+ const branches: BranchPair = yield* resolveBranchPairWithAgencyJson(
33
+ gitRoot,
34
+ currentBranch,
35
+ config.sourceBranchPattern,
36
+ config.emitBranch,
37
+ )
38
+ const { sourceBranch, emitBranch, isOnEmitBranch } = branches
39
+
40
+ if (isOnEmitBranch) {
41
+ // We're on an emit branch, switch to source
42
+ yield* ensureBranchExists(
43
+ gitRoot,
44
+ sourceBranch,
45
+ `Source branch ${highlight.branch(sourceBranch)} does not exist`,
46
+ )
47
+
48
+ yield* git.checkoutBranch(gitRoot, sourceBranch)
49
+ log(done(`Switched to source branch: ${highlight.branch(sourceBranch)}`))
50
+ } else {
51
+ // We're on a source branch, switch to emit branch
52
+ yield* ensureBranchExists(
53
+ gitRoot,
54
+ emitBranch,
55
+ `Emit branch ${highlight.branch(emitBranch)} does not exist. Run 'agency emit' to create it.`,
56
+ )
57
+
58
+ yield* git.checkoutBranch(gitRoot, emitBranch)
59
+ log(done(`Switched to ${highlight.branch(emitBranch)}`))
60
+ }
61
+ })
62
+
63
+ export const help = `
64
+ Usage: agency switch [options]
65
+
66
+ Toggle between source branch and emit branch.
67
+
68
+ Source and Emit Branches:
69
+ - Source branches: Your working branches with agency-specific files (e.g., agency/main)
70
+ - Emit branches: Clean branches suitable for PRs without agency files (e.g., main)
71
+
72
+ This command intelligently switches between your source branch and its
73
+ corresponding emit branch:
74
+ - If on an emit branch (e.g., main), switches to source (agency/main)
75
+ - If on a source branch (e.g., agency/main), switches to emit branch (main)
76
+
77
+ Example:
78
+ agency switch # Toggle between branches
79
+
80
+ Notes:
81
+ - Target branch must exist
82
+ - Uses source and emit patterns from ~/.config/agency/agency.json
83
+ - If emit branch doesn't exist, run 'agency emit' to create it
84
+ `