@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,162 @@
1
+ import { resolve, join, dirname, basename } from "path"
2
+ import { Effect } from "effect"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { TemplateService } from "../services/TemplateService"
5
+ import { FileSystemService } from "../services/FileSystemService"
6
+ import { RepositoryNotInitializedError } from "../errors"
7
+ import highlight, { done } from "../utils/colors"
8
+ import { createLoggers, ensureGitRepo, getTemplateName } from "../utils/effect"
9
+
10
+ interface SaveOptions extends BaseCommandOptions {
11
+ files?: string[]
12
+ }
13
+
14
+ function isDirectory(filePath: string): Effect.Effect<boolean, Error> {
15
+ return Effect.tryPromise({
16
+ try: async () => {
17
+ try {
18
+ const file = Bun.file(filePath)
19
+ const stat = await file.stat()
20
+ return stat?.isDirectory?.() ?? false
21
+ } catch {
22
+ return false
23
+ }
24
+ },
25
+ catch: (error) =>
26
+ new Error(`Failed to check if path is directory: ${error}`),
27
+ })
28
+ }
29
+
30
+ export const save = (options: SaveOptions = {}) =>
31
+ Effect.gen(function* () {
32
+ const { files: filesToSave = [] } = options
33
+ const { log, verboseLog } = createLoggers(options)
34
+
35
+ const templateService = yield* TemplateService
36
+ const fs = yield* FileSystemService
37
+
38
+ const gitRoot = yield* ensureGitRepo()
39
+
40
+ // Get template name from git config
41
+ const templateName = yield* getTemplateName(gitRoot)
42
+ if (!templateName) {
43
+ return yield* Effect.fail(new RepositoryNotInitializedError())
44
+ }
45
+
46
+ verboseLog(`Saving to template: ${highlight.template(templateName)}`)
47
+
48
+ // Get template directory
49
+ const templateDir = yield* templateService.getTemplateDir(templateName)
50
+
51
+ // Create template directory if it doesn't exist
52
+ yield* templateService.createTemplateDir(templateName)
53
+ verboseLog(`Ensured template directory exists: ${templateDir}`)
54
+
55
+ // Determine which files to save
56
+ let filesToProcess: string[] = []
57
+
58
+ if (filesToSave.length > 0) {
59
+ // Process provided file/dir names
60
+ for (const fileOrDir of filesToSave) {
61
+ const fullPath = resolve(gitRoot, fileOrDir)
62
+ const isDir = yield* isDirectory(fullPath)
63
+
64
+ if (isDir) {
65
+ // Recursively collect files from directory
66
+ const collected = yield* fs.collectFiles(fullPath, {
67
+ relativeTo: gitRoot,
68
+ })
69
+ filesToProcess.push(...collected)
70
+ } else {
71
+ // Add file path relative to git root
72
+ const relativePath = fileOrDir.startsWith(gitRoot)
73
+ ? fileOrDir.replace(gitRoot + "/", "")
74
+ : fileOrDir
75
+ filesToProcess.push(relativePath)
76
+ }
77
+ }
78
+ } else {
79
+ return yield* Effect.fail(
80
+ new Error(
81
+ "No files specified. Usage: agency save <file|dir> [file|dir ...]",
82
+ ),
83
+ )
84
+ }
85
+
86
+ // Save each file
87
+ for (const filePath of filesToProcess) {
88
+ const sourceFilePath = resolve(gitRoot, filePath)
89
+
90
+ // Check if file exists
91
+ const exists = yield* fs.exists(sourceFilePath)
92
+ if (!exists) {
93
+ verboseLog(`Skipping ${filePath} (does not exist)`)
94
+ continue
95
+ }
96
+
97
+ // Refuse to save TASK.md files - agency itself must control these
98
+ const fileName = basename(filePath)
99
+ if (fileName === "TASK.md") {
100
+ return yield* Effect.fail(
101
+ new Error(
102
+ `Cannot save ${filePath}: TASK.md files cannot be saved to templates. ` +
103
+ `Agency itself must control the creation of TASK.md files.`,
104
+ ),
105
+ )
106
+ }
107
+
108
+ // Read content
109
+ const content = yield* fs.readFile(sourceFilePath)
110
+
111
+ const templateFilePath = join(templateDir, filePath)
112
+
113
+ // Ensure directory exists
114
+ const dir = dirname(templateFilePath)
115
+ yield* fs.createDirectory(dir)
116
+
117
+ // Write to template
118
+ yield* fs.writeFile(templateFilePath, content)
119
+ log(
120
+ done(
121
+ `Saved ${highlight.file(filePath)} to ${highlight.template(templateName)} template`,
122
+ ),
123
+ )
124
+ }
125
+ })
126
+
127
+ // Help text for reference (not exported as it's handled by template command)
128
+ const help = `
129
+ Usage: agency save <file|dir> [file|dir ...] [options]
130
+
131
+ Save specified files or directories to the configured template.
132
+
133
+ This command copies files and directories from the current git repository to
134
+ the template directory configured in .git/config (agency.template). Directories
135
+ are saved recursively.
136
+
137
+ Arguments:
138
+ <file|dir> File or directory path to save (relative to git root)
139
+ [file|dir ...] Additional files or directories to save
140
+
141
+ Options:
142
+ -h, --help Show this help message
143
+ -s, --silent Suppress output messages
144
+ -v, --verbose Show verbose output
145
+
146
+ Examples:
147
+ agency save AGENTS.md # Save specific file
148
+ agency save .config # Save entire directory
149
+ agency save src/ # Save src directory to template
150
+ agency save AGENTS.md docs/ # Save file and directory
151
+ agency save --verbose # Save with verbose output
152
+ agency save --help # Show this help message
153
+
154
+ Notes:
155
+ - Requires agency.template to be set (run 'agency init' first)
156
+ - At least one file or directory must be specified
157
+ - Files are saved to ~/.config/agency/templates/{template-name}/
158
+ - Template directory is created automatically if it doesn't exist
159
+ - Existing template files will be overwritten
160
+ - Directory structure is preserved in the template
161
+ - TASK.md files cannot be saved - agency controls their creation
162
+ `
@@ -0,0 +1,195 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { source } from "./source"
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ initGitRepo,
8
+ getCurrentBranch,
9
+ createCommit,
10
+ runTestEffect,
11
+ } from "../test-utils"
12
+ import { writeAgencyMetadata } from "../types"
13
+
14
+ async function createBranch(cwd: string, branchName: string): Promise<void> {
15
+ await Bun.spawn(["git", "checkout", "-b", branchName], {
16
+ cwd,
17
+ stdout: "pipe",
18
+ stderr: "pipe",
19
+ }).exited
20
+ }
21
+
22
+ describe("source command", () => {
23
+ let tempDir: string
24
+ let originalCwd: string
25
+
26
+ beforeEach(async () => {
27
+ tempDir = await createTempDir()
28
+ originalCwd = process.cwd()
29
+ process.chdir(tempDir)
30
+
31
+ // Set config path to non-existent file to use defaults
32
+ process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
33
+
34
+ // Initialize git repo
35
+ await initGitRepo(tempDir)
36
+ await createCommit(tempDir, "Initial commit")
37
+
38
+ // Rename to main if needed
39
+ const currentBranch = await getCurrentBranch(tempDir)
40
+ if (currentBranch === "master") {
41
+ await Bun.spawn(["git", "branch", "-m", "main"], {
42
+ cwd: tempDir,
43
+ stdout: "pipe",
44
+ stderr: "pipe",
45
+ }).exited
46
+ }
47
+ })
48
+
49
+ afterEach(async () => {
50
+ process.chdir(originalCwd)
51
+ delete process.env.AGENCY_CONFIG_PATH
52
+ await cleanupTempDir(tempDir)
53
+ })
54
+
55
+ describe("basic functionality", () => {
56
+ test("switches from emit branch to source branch", async () => {
57
+ // Create source branch with agency.json
58
+ await createBranch(tempDir, "agency/feature")
59
+ await writeAgencyMetadata(tempDir, {
60
+ version: 1,
61
+ injectedFiles: [],
62
+ template: "test-template",
63
+ emitBranch: "feature",
64
+ createdAt: new Date().toISOString(),
65
+ } as any)
66
+ // Stage and commit agency.json
67
+ await Bun.spawn(["git", "add", "agency.json"], {
68
+ cwd: tempDir,
69
+ stdout: "pipe",
70
+ stderr: "pipe",
71
+ }).exited
72
+ await createCommit(tempDir, "Setup")
73
+
74
+ // Create emit branch and remove agency.json
75
+ await createBranch(tempDir, "feature")
76
+ await Bun.spawn(["rm", "agency.json"], {
77
+ cwd: tempDir,
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ }).exited
81
+
82
+ // Run source command (should switch from feature emit branch to agency/feature source)
83
+ await runTestEffect(source({ silent: true }))
84
+
85
+ // Should be on agency/feature now
86
+ const currentBranch = await getCurrentBranch(tempDir)
87
+ expect(currentBranch).toBe("agency/feature")
88
+ })
89
+
90
+ test("works with custom emit branch pattern", async () => {
91
+ // Create custom config with custom patterns
92
+ const configPath = join(tempDir, "custom-config.json")
93
+ await Bun.write(
94
+ configPath,
95
+ JSON.stringify({
96
+ sourceBranchPattern: "WIP/%branch%",
97
+ emitBranch: "PR/%branch%",
98
+ }),
99
+ )
100
+ process.env.AGENCY_CONFIG_PATH = configPath
101
+
102
+ // Create source branch with agency.json
103
+ await createBranch(tempDir, "WIP/feature")
104
+ await writeAgencyMetadata(tempDir, {
105
+ version: 1,
106
+ injectedFiles: [],
107
+ template: "test-template",
108
+ emitBranch: "PR/feature",
109
+ createdAt: new Date().toISOString(),
110
+ } as any)
111
+ // Stage and commit
112
+ await Bun.spawn(["git", "add", "agency.json"], {
113
+ cwd: tempDir,
114
+ stdout: "pipe",
115
+ stderr: "pipe",
116
+ }).exited
117
+ await createCommit(tempDir, "Feature work")
118
+
119
+ // Create emit branch
120
+ await createBranch(tempDir, "PR/feature")
121
+ await Bun.spawn(["rm", "agency.json"], {
122
+ cwd: tempDir,
123
+ stdout: "pipe",
124
+ stderr: "pipe",
125
+ }).exited
126
+
127
+ // Run source command (from PR/feature to WIP/feature)
128
+ await runTestEffect(source({ silent: true }))
129
+
130
+ // Should be on WIP/feature now
131
+ const currentBranch = await getCurrentBranch(tempDir)
132
+ expect(currentBranch).toBe("WIP/feature")
133
+ })
134
+ })
135
+
136
+ describe("error handling", () => {
137
+ test("throws error when not on an emit branch", async () => {
138
+ // We're on main, which is not an emit branch
139
+ await expect(runTestEffect(source({ silent: true }))).rejects.toThrow(
140
+ "Not on an emit branch",
141
+ )
142
+ })
143
+
144
+ test("throws error when not in a git repository", async () => {
145
+ const nonGitDir = await createTempDir()
146
+ process.chdir(nonGitDir)
147
+
148
+ await expect(runTestEffect(source({ silent: true }))).rejects.toThrow(
149
+ "Not in a git repository",
150
+ )
151
+
152
+ await cleanupTempDir(nonGitDir)
153
+ })
154
+ })
155
+
156
+ describe("silent mode", () => {
157
+ test("silent flag suppresses output", async () => {
158
+ // Create source branch with agency.json
159
+ await createBranch(tempDir, "agency/feature")
160
+ await writeAgencyMetadata(tempDir, {
161
+ version: 1,
162
+ injectedFiles: [],
163
+ template: "test-template",
164
+ emitBranch: "feature",
165
+ createdAt: new Date().toISOString(),
166
+ } as any)
167
+ await Bun.spawn(["git", "add", "agency.json"], {
168
+ cwd: tempDir,
169
+ stdout: "pipe",
170
+ stderr: "pipe",
171
+ }).exited
172
+ await createCommit(tempDir, "Setup")
173
+
174
+ // Create emit branch
175
+ await createBranch(tempDir, "feature")
176
+ await Bun.spawn(["rm", "agency.json"], {
177
+ cwd: tempDir,
178
+ stdout: "pipe",
179
+ stderr: "pipe",
180
+ }).exited
181
+
182
+ // Capture output
183
+ const originalLog = console.log
184
+ let logCalled = false
185
+ console.log = () => {
186
+ logCalled = true
187
+ }
188
+
189
+ await runTestEffect(source({ silent: true }))
190
+
191
+ console.log = originalLog
192
+ expect(logCalled).toBe(false)
193
+ })
194
+ })
195
+ })
@@ -0,0 +1,72 @@
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 { FileSystemService } from "../services/FileSystemService"
6
+ import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
7
+ import highlight, { done } from "../utils/colors"
8
+ import {
9
+ createLoggers,
10
+ ensureGitRepo,
11
+ ensureBranchExists,
12
+ } from "../utils/effect"
13
+
14
+ interface SourceOptions extends BaseCommandOptions {}
15
+
16
+ export const source = (options: SourceOptions = {}) =>
17
+ Effect.gen(function* () {
18
+ const { log } = createLoggers(options)
19
+
20
+ const git = yield* GitService
21
+ const configService = yield* ConfigService
22
+
23
+ const gitRoot = yield* ensureGitRepo()
24
+
25
+ // Load config
26
+ const config = yield* configService.loadConfig()
27
+
28
+ // Get current branch and resolve the branch pair
29
+ const currentBranch = yield* git.getCurrentBranch(gitRoot)
30
+ const { sourceBranch, isOnEmitBranch } =
31
+ yield* resolveBranchPairWithAgencyJson(
32
+ gitRoot,
33
+ currentBranch,
34
+ config.sourceBranchPattern,
35
+ config.emitBranch,
36
+ )
37
+
38
+ if (!isOnEmitBranch) {
39
+ return yield* Effect.fail(
40
+ new Error(`Not on an emit branch. Current branch: ${currentBranch}`),
41
+ )
42
+ }
43
+
44
+ // Check if source branch exists
45
+ yield* ensureBranchExists(
46
+ gitRoot,
47
+ sourceBranch,
48
+ `Source branch ${highlight.branch(sourceBranch)} does not exist`,
49
+ )
50
+
51
+ // Checkout source branch
52
+ yield* git.checkoutBranch(gitRoot, sourceBranch)
53
+
54
+ log(done(`Switched to source branch: ${highlight.branch(sourceBranch)}`))
55
+ })
56
+
57
+ export const help = `
58
+ Usage: agency source [options]
59
+
60
+ Switch back to the source branch from an emit branch.
61
+
62
+ This command extracts the source branch name from your current emit branch name
63
+ using the configured pattern, and switches back to it.
64
+
65
+ Example:
66
+ agency source # From main--PR, switch to main
67
+
68
+ Notes:
69
+ - Must be run from an emit branch
70
+ - Source branch must exist
71
+ - Uses emit branch pattern from ~/.config/agency/agency.json
72
+ `