@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,362 @@
1
+ import { mkdtemp, rm, cp } from "fs/promises"
2
+ import { tmpdir } from "os"
3
+ import { join } from "path"
4
+
5
+ // Cache a template git repository to speed up test setup
6
+ let templateGitRepo: string | null = null
7
+ let templateGitRepoPromise: Promise<string> | null = null
8
+
9
+ async function getTemplateGitRepo(): Promise<string> {
10
+ // Return cached result if available
11
+ if (templateGitRepo) {
12
+ return templateGitRepo
13
+ }
14
+
15
+ // Use a promise to prevent concurrent initialization (race condition fix)
16
+ if (templateGitRepoPromise) {
17
+ return templateGitRepoPromise
18
+ }
19
+
20
+ // Create and cache the initialization promise
21
+ templateGitRepoPromise = (async () => {
22
+ // Create a template git repo once and reuse it
23
+ const tempDir = await mkdtemp(join(tmpdir(), "agency-template-"))
24
+
25
+ const proc = Bun.spawn(["git", "init", "-b", "main"], {
26
+ cwd: tempDir,
27
+ stdout: "pipe",
28
+ stderr: "pipe",
29
+ })
30
+ await proc.exited
31
+
32
+ if (proc.exitCode !== 0) {
33
+ throw new Error("Failed to initialize template git repository")
34
+ }
35
+
36
+ // Write config directly
37
+ const configFile = Bun.file(join(tempDir, ".git", "config"))
38
+ const existingConfig = await configFile.text()
39
+ const newConfig =
40
+ existingConfig +
41
+ "\n[user]\n\temail = test@example.com\n\tname = Test User\n[core]\n\thooksPath = /dev/null\n"
42
+ await Bun.write(join(tempDir, ".git", "config"), newConfig)
43
+
44
+ // Create initial commit
45
+ await Bun.write(join(tempDir, ".gitkeep"), "")
46
+ await Bun.spawn(["git", "add", ".gitkeep"], {
47
+ cwd: tempDir,
48
+ stdout: "pipe",
49
+ stderr: "pipe",
50
+ }).exited
51
+ await Bun.spawn(["git", "commit", "-m", "Initial commit"], {
52
+ cwd: tempDir,
53
+ stdout: "pipe",
54
+ stderr: "pipe",
55
+ }).exited
56
+
57
+ templateGitRepo = tempDir
58
+ return tempDir
59
+ })()
60
+
61
+ return templateGitRepoPromise
62
+ }
63
+
64
+ /**
65
+ * Create a temporary directory for testing
66
+ */
67
+ export async function createTempDir(): Promise<string> {
68
+ return await mkdtemp(join(tmpdir(), "agency-test-"))
69
+ }
70
+
71
+ /**
72
+ * Clean up a temporary directory
73
+ */
74
+ export async function cleanupTempDir(path: string): Promise<void> {
75
+ try {
76
+ await rm(path, { recursive: true, force: true })
77
+ } catch (error) {
78
+ // Ignore errors during cleanup
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Initialize a git repository in a directory
84
+ * Uses a cached template repository for much faster setup
85
+ */
86
+ export async function initGitRepo(path: string): Promise<void> {
87
+ const template = await getTemplateGitRepo()
88
+
89
+ // Copy the template .git directory
90
+ await cp(join(template, ".git"), join(path, ".git"), {
91
+ recursive: true,
92
+ })
93
+
94
+ // Copy the .gitkeep file
95
+ await cp(join(template, ".gitkeep"), join(path, ".gitkeep"))
96
+ }
97
+
98
+ /**
99
+ * Create a subdirectory in a path
100
+ */
101
+ export async function createSubdir(
102
+ basePath: string,
103
+ name: string,
104
+ ): Promise<string> {
105
+ const subdirPath = join(basePath, name)
106
+ await Bun.write(join(subdirPath, ".gitkeep"), "")
107
+ return subdirPath
108
+ }
109
+
110
+ /**
111
+ * Check if a file exists
112
+ */
113
+ export async function fileExists(path: string): Promise<boolean> {
114
+ const file = Bun.file(path)
115
+ return await file.exists()
116
+ }
117
+
118
+ /**
119
+ * Read file content
120
+ */
121
+ export async function readFile(path: string): Promise<string> {
122
+ const file = Bun.file(path)
123
+ return await file.text()
124
+ }
125
+
126
+ /**
127
+ * Execute a git command and return its output
128
+ */
129
+ export async function getGitOutput(
130
+ cwd: string,
131
+ args: string[],
132
+ ): Promise<string> {
133
+ const proc = Bun.spawn(["git", ...args], {
134
+ cwd,
135
+ stdout: "pipe",
136
+ stderr: "pipe",
137
+ })
138
+ await proc.exited
139
+ return await new Response(proc.stdout).text()
140
+ }
141
+
142
+ /**
143
+ * Get the current branch name in a git repository
144
+ */
145
+ export async function getCurrentBranch(cwd: string): Promise<string> {
146
+ const output = await getGitOutput(cwd, ["branch", "--show-current"])
147
+ return output.trim()
148
+ }
149
+
150
+ /**
151
+ * Run a git command directly - fire and forget version (fastest)
152
+ */
153
+ async function gitRun(cwd: string, args: string[]): Promise<void> {
154
+ const proc = Bun.spawn(["git", ...args], {
155
+ cwd,
156
+ stdout: "pipe",
157
+ stderr: "pipe",
158
+ })
159
+ await proc.exited
160
+ }
161
+
162
+ /**
163
+ * Create a test commit in a git repository
164
+ */
165
+ export async function createCommit(
166
+ cwd: string,
167
+ message: string,
168
+ ): Promise<void> {
169
+ // Create a test file and commit it
170
+ await Bun.write(join(cwd, "test.txt"), message)
171
+ await gitRun(cwd, ["add", "test.txt"])
172
+ await gitRun(cwd, ["commit", "--no-verify", "-m", message])
173
+ }
174
+
175
+ /**
176
+ * Checkout a branch in a git repository
177
+ */
178
+ export async function checkoutBranch(
179
+ cwd: string,
180
+ branchName: string,
181
+ ): Promise<void> {
182
+ await gitRun(cwd, ["checkout", branchName])
183
+ }
184
+
185
+ /**
186
+ * Create a new branch and switch to it
187
+ */
188
+ export async function createBranch(
189
+ cwd: string,
190
+ branchName: string,
191
+ ): Promise<void> {
192
+ await gitRun(cwd, ["checkout", "-b", branchName])
193
+ }
194
+
195
+ /**
196
+ * Stage files and commit in a single operation
197
+ */
198
+ export async function addAndCommit(
199
+ cwd: string,
200
+ files: string | string[],
201
+ message: string,
202
+ ): Promise<void> {
203
+ const fileList = Array.isArray(files) ? files : files.split(" ")
204
+ await gitRun(cwd, ["add", ...fileList])
205
+ await gitRun(cwd, ["commit", "--no-verify", "-m", message])
206
+ }
207
+
208
+ /**
209
+ * Setup a remote and fetch in a single operation
210
+ */
211
+ export async function setupRemote(
212
+ cwd: string,
213
+ remoteName: string,
214
+ remoteUrl: string,
215
+ ): Promise<void> {
216
+ await gitRun(cwd, ["remote", "add", remoteName, remoteUrl])
217
+ await gitRun(cwd, ["fetch", remoteName])
218
+ }
219
+
220
+ /**
221
+ * Delete a branch
222
+ */
223
+ export async function deleteBranch(
224
+ cwd: string,
225
+ branchName: string,
226
+ force: boolean = false,
227
+ ): Promise<void> {
228
+ const flag = force ? "-D" : "-d"
229
+ await gitRun(cwd, ["branch", flag, branchName])
230
+ }
231
+
232
+ /**
233
+ * Rename current branch
234
+ */
235
+ export async function renameBranch(
236
+ cwd: string,
237
+ newName: string,
238
+ ): Promise<void> {
239
+ await gitRun(cwd, ["branch", "-m", newName])
240
+ }
241
+
242
+ /**
243
+ * Check if a branch exists in a git repository
244
+ */
245
+ export async function branchExists(
246
+ cwd: string,
247
+ branch: string,
248
+ ): Promise<boolean> {
249
+ const proc = Bun.spawn(["git", "rev-parse", "--verify", branch], {
250
+ cwd,
251
+ stdout: "pipe",
252
+ stderr: "pipe",
253
+ })
254
+ await proc.exited
255
+ return proc.exitCode === 0
256
+ }
257
+
258
+ /**
259
+ * Initialize a repository with agency by setting a template in git config
260
+ */
261
+ export async function initAgency(
262
+ cwd: string,
263
+ templateName: string,
264
+ ): Promise<void> {
265
+ await Bun.spawn(
266
+ ["git", "config", "--local", "agency.template", templateName],
267
+ {
268
+ cwd,
269
+ stdout: "pipe",
270
+ stderr: "pipe",
271
+ },
272
+ ).exited
273
+ }
274
+
275
+ /**
276
+ * Get a git config value for testing
277
+ */
278
+ export async function getGitConfig(
279
+ key: string,
280
+ gitRoot: string,
281
+ ): Promise<string | null> {
282
+ try {
283
+ const proc = Bun.spawn(["git", "config", "--local", "--get", key], {
284
+ cwd: gitRoot,
285
+ stdout: "pipe",
286
+ stderr: "pipe",
287
+ })
288
+ await proc.exited // Must await before reading stdout to prevent hangs in concurrent tests
289
+ const output = await new Response(proc.stdout).text()
290
+ return output.trim() || null
291
+ } catch {
292
+ return null
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Run a git command
298
+ */
299
+ export async function runGitCommand(
300
+ cwd: string,
301
+ args: string[],
302
+ ): Promise<void> {
303
+ const proc = Bun.spawn(args, {
304
+ cwd,
305
+ stdout: "pipe",
306
+ stderr: "pipe",
307
+ })
308
+ await proc.exited
309
+ if (proc.exitCode !== 0) {
310
+ const stderr = await new Response(proc.stderr).text()
311
+ throw new Error(`Git command failed: ${args.join(" ")}\n${stderr}`)
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Create a file with content
317
+ */
318
+ export async function createFile(
319
+ cwd: string,
320
+ filename: string,
321
+ content: string,
322
+ ): Promise<void> {
323
+ await Bun.write(join(cwd, filename), content)
324
+ }
325
+
326
+ /**
327
+ * Run an Effect in tests with all services provided
328
+ */
329
+ import { Effect, Layer } from "effect"
330
+ import { GitService } from "./services/GitService"
331
+ import { ConfigService } from "./services/ConfigService"
332
+ import { FileSystemService } from "./services/FileSystemService"
333
+ import { PromptService } from "./services/PromptService"
334
+ import { TemplateService } from "./services/TemplateService"
335
+ import { OpencodeService } from "./services/OpencodeService"
336
+ import { ClaudeService } from "./services/ClaudeService"
337
+
338
+ // Create test layer with all services
339
+ const TestLayer = Layer.mergeAll(
340
+ GitService.Default,
341
+ ConfigService.Default,
342
+ FileSystemService.Default,
343
+ PromptService.Default,
344
+ TemplateService.Default,
345
+ OpencodeService.Default,
346
+ ClaudeService.Default,
347
+ )
348
+
349
+ export async function runTestEffect<A, E>(
350
+ effect: Effect.Effect<A, E, any>,
351
+ ): Promise<A> {
352
+ const providedEffect = Effect.provide(effect, TestLayer) as Effect.Effect<
353
+ A,
354
+ E,
355
+ never
356
+ >
357
+ const program = Effect.catchAllDefect(providedEffect, (defect) =>
358
+ Effect.fail(defect instanceof Error ? defect : new Error(String(defect))),
359
+ ) as Effect.Effect<A, E | Error, never>
360
+
361
+ return await Effect.runPromise(program)
362
+ }
@@ -0,0 +1,8 @@
1
+ declare module "@triggi/native-exec" {
2
+ function exec(
3
+ command: string,
4
+ env?: NodeJS.ProcessEnv,
5
+ ...args: string[]
6
+ ): void
7
+ export default exec
8
+ }
package/src/types.ts ADDED
@@ -0,0 +1,216 @@
1
+ import { join } from "node:path"
2
+ import { Effect } from "effect"
3
+ import { Schema } from "@effect/schema"
4
+ import { ManagedFile, AgencyMetadata } from "./schemas"
5
+ import {
6
+ AgencyMetadataService,
7
+ AgencyMetadataServiceLive,
8
+ } from "./services/AgencyMetadataService"
9
+ import { FileSystemService } from "./services/FileSystemService"
10
+ import { GitService } from "./services/GitService"
11
+
12
+ export interface Command {
13
+ name: string
14
+ description: string
15
+ run: (args: string[], options: Record<string, any>) => Promise<void>
16
+ help?: string
17
+ }
18
+
19
+ /**
20
+ * Load template content from the templates directory.
21
+ * Falls back to inline defaults if files cannot be read (e.g., in bundled packages).
22
+ */
23
+ async function loadTemplateContent(fileName: string): Promise<string> {
24
+ try {
25
+ // Try to load from the templates directory relative to this file's location
26
+ const templatePath = join(import.meta.dir, "..", "templates", fileName)
27
+ const file = Bun.file(templatePath)
28
+ if (await file.exists()) {
29
+ return await file.text()
30
+ }
31
+ } catch (error) {
32
+ // Fall through to defaults if file loading fails
33
+ }
34
+
35
+ // Return inline defaults as fallback
36
+ const defaults: Record<string, string> = {
37
+ "AGENCY.md": `# Agent Instructions
38
+
39
+ ## TASK.md
40
+
41
+ The \`TASK.md\` file describes the task being performed and should be kept updated as work progresses. This file serves as a living record of:
42
+
43
+ - What is being built or fixed
44
+ - Current progress and status
45
+ - Remaining work items
46
+ - Any important context or decisions
47
+
48
+ All work on this repository should begin by reading and understanding \`TASK.md\`. Whenever any significant progress is made, \`TASK.md\` should be updated to reflect the current state of work.
49
+
50
+ See \`TASK.md\` for the current task description and progress.
51
+ `,
52
+ "AGENTS.md": `# Agency
53
+
54
+ Agency is a CLI tool for managing \`AGENTS.md\`, \`TASK.md\`, and \`opencode.json\` files in git repositories. It helps coordinate work across multiple branches and templates.
55
+
56
+ ## Key Commands
57
+
58
+ - \`agency task\` - Initialize template files on a feature branch
59
+ - \`agency edit\` - Open TASK.md in system editor
60
+ - \`agency template save\` - Save current file versions back to a template
61
+ - \`agency template use\` - Switch to a different template
62
+ - \`agency emit\` - Create an emit branch with managed files reverted to their merge-base state
63
+ - \`agency switch\` - Toggle between feature and emit branches
64
+ - \`agency template source\` - Get the path to a template's source directory
65
+ - \`agency set-base\` - Update the saved base branch for emit creation
66
+
67
+ ## Features
68
+
69
+ - **Template-based workflow** - Reusable templates stored in \`~/.config/agency/templates/\`
70
+ - **Git integration** - Saves template configuration in \`.git/config\`
71
+ - **Emit branch management** - Automatically creates clean emit branches without local modifications
72
+ - **Multi-file support** - Manages AGENTS.md, TASK.md, and opencode.json
73
+ `,
74
+ "TASK.md": `{task}
75
+
76
+ ## Tasks
77
+
78
+ - [ ] Populate this list
79
+ `,
80
+ "opencode.json": JSON.stringify(
81
+ {
82
+ $schema: "https://opencode.ai/config.json",
83
+ instructions: ["AGENCY.md", "TASK.md"],
84
+ },
85
+ null,
86
+ 2,
87
+ ),
88
+ }
89
+
90
+ return defaults[fileName] || ""
91
+ }
92
+
93
+ /**
94
+ * Initialize managed files with their default content.
95
+ * This is a synchronous function that returns a promise for the initialized files.
96
+ */
97
+ export async function initializeManagedFiles(): Promise<ManagedFile[]> {
98
+ const files: ManagedFile[] = []
99
+
100
+ for (const fileName of [
101
+ "AGENCY.md",
102
+ "AGENTS.md",
103
+ "opencode.json",
104
+ "TASK.md",
105
+ ]) {
106
+ const content = await loadTemplateContent(fileName)
107
+ files.push({
108
+ name: fileName,
109
+ defaultContent: content,
110
+ })
111
+ }
112
+
113
+ return files
114
+ }
115
+
116
+ // This will be initialized by commands that need it
117
+ // For backward compatibility, export a variable that can be set
118
+ let MANAGED_FILES: ManagedFile[] = []
119
+
120
+ // Validation is now handled by Effect schemas in schemas.ts
121
+
122
+ /**
123
+ * Read agency.json metadata from a repository.
124
+ * @deprecated Use AgencyMetadataService.readFromDisk instead
125
+ */
126
+ export async function readAgencyMetadata(
127
+ gitRoot: string,
128
+ ): Promise<AgencyMetadata | null> {
129
+ const program = Effect.gen(function* () {
130
+ const metadataService = yield* AgencyMetadataService
131
+ return yield* metadataService.readFromDisk(gitRoot)
132
+ }).pipe(
133
+ Effect.provide(AgencyMetadataServiceLive),
134
+ Effect.provide(FileSystemService.Default),
135
+ Effect.provide(GitService.Default),
136
+ )
137
+
138
+ return Effect.runPromise(program)
139
+ }
140
+
141
+ /**
142
+ * Write agency.json metadata to a repository.
143
+ * @deprecated Use AgencyMetadataService.write instead
144
+ */
145
+ export async function writeAgencyMetadata(
146
+ gitRoot: string,
147
+ metadata: AgencyMetadata,
148
+ ): Promise<void> {
149
+ const program = Effect.gen(function* () {
150
+ const metadataService = yield* AgencyMetadataService
151
+ return yield* metadataService.write(gitRoot, metadata)
152
+ }).pipe(
153
+ Effect.provide(AgencyMetadataServiceLive),
154
+ Effect.provide(FileSystemService.Default),
155
+ Effect.provide(GitService.Default),
156
+ )
157
+
158
+ return Effect.runPromise(program)
159
+ }
160
+
161
+ /**
162
+ * Get list of files to filter during PR/merge operations.
163
+ * Always includes TASK.md, AGENCY.md, and agency.json, plus any backpack files from metadata.
164
+ * @deprecated Use AgencyMetadataService.getFilesToFilter instead
165
+ */
166
+ export async function getFilesToFilter(gitRoot: string): Promise<string[]> {
167
+ const program = Effect.gen(function* () {
168
+ const metadataService = yield* AgencyMetadataService
169
+ return yield* metadataService.getFilesToFilter(gitRoot)
170
+ }).pipe(
171
+ Effect.provide(AgencyMetadataServiceLive),
172
+ Effect.provide(FileSystemService.Default),
173
+ Effect.provide(GitService.Default),
174
+ )
175
+
176
+ return Effect.runPromise(program)
177
+ }
178
+
179
+ /**
180
+ * Get the configured base branch from agency.json metadata.
181
+ * @deprecated Use AgencyMetadataService.getBaseBranch instead
182
+ */
183
+ export async function getBaseBranchFromMetadata(
184
+ gitRoot: string,
185
+ ): Promise<string | null> {
186
+ const program = Effect.gen(function* () {
187
+ const metadataService = yield* AgencyMetadataService
188
+ return yield* metadataService.getBaseBranch(gitRoot)
189
+ }).pipe(
190
+ Effect.provide(AgencyMetadataServiceLive),
191
+ Effect.provide(FileSystemService.Default),
192
+ Effect.provide(GitService.Default),
193
+ )
194
+
195
+ return Effect.runPromise(program)
196
+ }
197
+
198
+ /**
199
+ * Set the base branch in agency.json metadata.
200
+ * @deprecated Use AgencyMetadataService.setBaseBranch instead
201
+ */
202
+ export async function setBaseBranchInMetadata(
203
+ gitRoot: string,
204
+ baseBranch: string,
205
+ ): Promise<void> {
206
+ const program = Effect.gen(function* () {
207
+ const metadataService = yield* AgencyMetadataService
208
+ return yield* metadataService.setBaseBranch(gitRoot, baseBranch)
209
+ }).pipe(
210
+ Effect.provide(AgencyMetadataServiceLive),
211
+ Effect.provide(FileSystemService.Default),
212
+ Effect.provide(GitService.Default),
213
+ )
214
+
215
+ return Effect.runPromise(program)
216
+ }