@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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/cli.ts +569 -0
- package/index.ts +1 -0
- package/package.json +65 -0
- package/src/commands/base.test.ts +198 -0
- package/src/commands/base.ts +198 -0
- package/src/commands/clean.test.ts +299 -0
- package/src/commands/clean.ts +320 -0
- package/src/commands/emit.test.ts +412 -0
- package/src/commands/emit.ts +521 -0
- package/src/commands/emitted.test.ts +226 -0
- package/src/commands/emitted.ts +57 -0
- package/src/commands/init.test.ts +311 -0
- package/src/commands/init.ts +140 -0
- package/src/commands/merge.test.ts +365 -0
- package/src/commands/merge.ts +253 -0
- package/src/commands/pull.test.ts +385 -0
- package/src/commands/pull.ts +205 -0
- package/src/commands/push.test.ts +394 -0
- package/src/commands/push.ts +346 -0
- package/src/commands/save.test.ts +247 -0
- package/src/commands/save.ts +162 -0
- package/src/commands/source.test.ts +195 -0
- package/src/commands/source.ts +72 -0
- package/src/commands/status.test.ts +489 -0
- package/src/commands/status.ts +258 -0
- package/src/commands/switch.test.ts +194 -0
- package/src/commands/switch.ts +84 -0
- package/src/commands/task-branching.test.ts +334 -0
- package/src/commands/task-edit.test.ts +141 -0
- package/src/commands/task-main.test.ts +872 -0
- package/src/commands/task.ts +712 -0
- package/src/commands/tasks.test.ts +335 -0
- package/src/commands/tasks.ts +155 -0
- package/src/commands/template-delete.test.ts +178 -0
- package/src/commands/template-delete.ts +98 -0
- package/src/commands/template-list.test.ts +135 -0
- package/src/commands/template-list.ts +87 -0
- package/src/commands/template-view.test.ts +158 -0
- package/src/commands/template-view.ts +86 -0
- package/src/commands/template.test.ts +32 -0
- package/src/commands/template.ts +96 -0
- package/src/commands/use.test.ts +87 -0
- package/src/commands/use.ts +97 -0
- package/src/commands/work.test.ts +462 -0
- package/src/commands/work.ts +193 -0
- package/src/errors.ts +17 -0
- package/src/schemas.ts +33 -0
- package/src/services/AgencyMetadataService.ts +287 -0
- package/src/services/ClaudeService.test.ts +184 -0
- package/src/services/ClaudeService.ts +91 -0
- package/src/services/ConfigService.ts +115 -0
- package/src/services/FileSystemService.ts +222 -0
- package/src/services/GitService.ts +751 -0
- package/src/services/OpencodeService.ts +263 -0
- package/src/services/PromptService.ts +183 -0
- package/src/services/TemplateService.ts +75 -0
- package/src/test-utils.ts +362 -0
- package/src/types/native-exec.d.ts +8 -0
- package/src/types.ts +216 -0
- package/src/utils/colors.ts +178 -0
- package/src/utils/command.ts +17 -0
- package/src/utils/effect.ts +281 -0
- package/src/utils/exec.ts +48 -0
- package/src/utils/paths.ts +51 -0
- package/src/utils/pr-branch.test.ts +372 -0
- package/src/utils/pr-branch.ts +473 -0
- package/src/utils/process.ts +110 -0
- package/src/utils/spinner.ts +82 -0
- package/templates/AGENCY.md +20 -0
- package/templates/AGENTS.md +11 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/TASK.md +5 -0
- package/templates/opencode.json +4 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { Effect, Either } from "effect"
|
|
2
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
3
|
+
import { GitService } from "../services/GitService"
|
|
4
|
+
import { ConfigService } from "../services/ConfigService"
|
|
5
|
+
import {
|
|
6
|
+
extractSourceBranch,
|
|
7
|
+
makePrBranchName,
|
|
8
|
+
resolveBranchPairWithAgencyJson,
|
|
9
|
+
} from "../utils/pr-branch"
|
|
10
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
11
|
+
import { emit } from "./emit"
|
|
12
|
+
import highlight, { done } from "../utils/colors"
|
|
13
|
+
import {
|
|
14
|
+
createLoggers,
|
|
15
|
+
ensureGitRepo,
|
|
16
|
+
getRemoteName,
|
|
17
|
+
withBranchProtection,
|
|
18
|
+
} from "../utils/effect"
|
|
19
|
+
import { withSpinner } from "../utils/spinner"
|
|
20
|
+
import { spawnProcess } from "../utils/process"
|
|
21
|
+
|
|
22
|
+
interface PushOptions extends BaseCommandOptions {
|
|
23
|
+
baseBranch?: string
|
|
24
|
+
branch?: string
|
|
25
|
+
force?: boolean
|
|
26
|
+
pr?: boolean
|
|
27
|
+
skipFilter?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const push = (options: PushOptions = {}) =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const gitRoot = yield* ensureGitRepo()
|
|
33
|
+
|
|
34
|
+
// Wrap the entire push operation with branch protection
|
|
35
|
+
// This ensures we return to the original branch on Ctrl-C interrupt
|
|
36
|
+
yield* withBranchProtection(gitRoot, pushCore(gitRoot, options))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const pushCore = (gitRoot: string, options: PushOptions) =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const { verbose = false } = options
|
|
42
|
+
const { log, verboseLog } = createLoggers(options)
|
|
43
|
+
|
|
44
|
+
const git = yield* GitService
|
|
45
|
+
const configService = yield* ConfigService
|
|
46
|
+
|
|
47
|
+
// Load config to check emit branch pattern
|
|
48
|
+
const config = yield* configService.loadConfig()
|
|
49
|
+
|
|
50
|
+
// Get current branch
|
|
51
|
+
let sourceBranch = yield* git.getCurrentBranch(gitRoot)
|
|
52
|
+
|
|
53
|
+
// Check if we're already on an emit branch using proper branch resolution
|
|
54
|
+
const fs = yield* FileSystemService
|
|
55
|
+
const branchInfo = yield* resolveBranchPairWithAgencyJson(
|
|
56
|
+
gitRoot,
|
|
57
|
+
sourceBranch,
|
|
58
|
+
config.sourceBranchPattern,
|
|
59
|
+
config.emitBranch,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
// If we're on an emit branch, switch to the source branch first
|
|
63
|
+
if (branchInfo.isOnEmitBranch) {
|
|
64
|
+
const actualSourceBranch = branchInfo.sourceBranch
|
|
65
|
+
// Check if the source branch exists
|
|
66
|
+
const sourceExists = yield* git.branchExists(gitRoot, actualSourceBranch)
|
|
67
|
+
if (sourceExists) {
|
|
68
|
+
verboseLog(
|
|
69
|
+
`Currently on emit branch ${highlight.branch(sourceBranch)}, switching to source branch ${highlight.branch(actualSourceBranch)}`,
|
|
70
|
+
)
|
|
71
|
+
yield* git.checkoutBranch(gitRoot, actualSourceBranch)
|
|
72
|
+
sourceBranch = actualSourceBranch
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
verboseLog(`Starting push workflow from ${highlight.branch(sourceBranch)}`)
|
|
77
|
+
|
|
78
|
+
// Step 1: Create emit branch (agency emit)
|
|
79
|
+
verboseLog("Step 1: Emitting...")
|
|
80
|
+
// Use emit command
|
|
81
|
+
const prEffectWithOptions = emit({
|
|
82
|
+
baseBranch: options.baseBranch,
|
|
83
|
+
branch: options.branch,
|
|
84
|
+
silent: true, // Suppress emit command output, we'll provide our own
|
|
85
|
+
force: options.force,
|
|
86
|
+
verbose: options.verbose,
|
|
87
|
+
skipFilter: options.skipFilter,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const prResult = yield* Effect.either(prEffectWithOptions)
|
|
91
|
+
if (Either.isLeft(prResult)) {
|
|
92
|
+
const error = prResult.left
|
|
93
|
+
return yield* Effect.fail(
|
|
94
|
+
new Error(
|
|
95
|
+
`Failed to create emit branch: ${error instanceof Error ? error.message : String(error)}`,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Compute the emit branch name (emit() command now stays on source branch)
|
|
101
|
+
// Use the branchInfo we already computed earlier
|
|
102
|
+
const emitBranchName = options.branch || branchInfo.emitBranch
|
|
103
|
+
log(done(`Emitted ${highlight.branch(emitBranchName)}`))
|
|
104
|
+
|
|
105
|
+
// Step 2: Push to remote (git push)
|
|
106
|
+
const remote = yield* getRemoteName(gitRoot)
|
|
107
|
+
verboseLog(
|
|
108
|
+
`Step 2: Pushing ${highlight.branch(emitBranchName)} to ${highlight.remote(remote)}...`,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const pushEither = yield* Effect.either(
|
|
112
|
+
withSpinner(
|
|
113
|
+
pushBranchToRemoteEffect(gitRoot, emitBranchName, remote, {
|
|
114
|
+
force: options.force,
|
|
115
|
+
verbose: options.verbose,
|
|
116
|
+
}),
|
|
117
|
+
{
|
|
118
|
+
text: options.force
|
|
119
|
+
? `Pushing to ${highlight.remote(remote)} (forced)`
|
|
120
|
+
: `Pushing to ${highlight.remote(remote)}`,
|
|
121
|
+
enabled: !options.silent && !options.verbose,
|
|
122
|
+
},
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
if (Either.isLeft(pushEither)) {
|
|
126
|
+
const error = pushEither.left
|
|
127
|
+
// If push failed, switch back to source branch before rethrowing
|
|
128
|
+
verboseLog(
|
|
129
|
+
"Push failed, switching back to source branch before reporting error...",
|
|
130
|
+
)
|
|
131
|
+
yield* git.checkoutBranch(gitRoot, sourceBranch)
|
|
132
|
+
return yield* Effect.fail(error)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const usedForce = pushEither.right
|
|
136
|
+
|
|
137
|
+
if (usedForce) {
|
|
138
|
+
log(done(`Pushed to ${highlight.remote(remote)} (forced)`))
|
|
139
|
+
} else {
|
|
140
|
+
log(done(`Pushed to ${highlight.remote(remote)}`))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Step 3 (optional): Open GitHub PR if --pr flag is set
|
|
144
|
+
if (options.pr) {
|
|
145
|
+
verboseLog("Step 3: Opening GitHub PR...")
|
|
146
|
+
|
|
147
|
+
const ghEither = yield* Effect.either(
|
|
148
|
+
openGitHubPR(gitRoot, emitBranchName, {
|
|
149
|
+
verbose: options.verbose,
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if (Either.isLeft(ghEither)) {
|
|
154
|
+
const error = ghEither.left
|
|
155
|
+
// Don't fail the entire command if gh fails, just warn
|
|
156
|
+
console.error(
|
|
157
|
+
`⚠ Failed to open GitHub PR: ${error instanceof Error ? error.message : String(error)}`,
|
|
158
|
+
)
|
|
159
|
+
} else {
|
|
160
|
+
log(done("Opened GitHub PR in browser"))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Verify we're still on the source branch (emit() now stays on source branch)
|
|
165
|
+
const finalBranch = yield* git.getCurrentBranch(gitRoot)
|
|
166
|
+
if (finalBranch !== sourceBranch) {
|
|
167
|
+
// This shouldn't happen with the new emit() behavior, but check anyway
|
|
168
|
+
verboseLog(
|
|
169
|
+
`Switching back to source branch ${highlight.branch(sourceBranch)}...`,
|
|
170
|
+
)
|
|
171
|
+
yield* git.checkoutBranch(gitRoot, sourceBranch)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// Helper: Push branch to remote with optional force and retry logic
|
|
176
|
+
const pushBranchToRemoteEffect = (
|
|
177
|
+
gitRoot: string,
|
|
178
|
+
branchName: string,
|
|
179
|
+
remote: string,
|
|
180
|
+
options: {
|
|
181
|
+
readonly force?: boolean
|
|
182
|
+
readonly verbose?: boolean
|
|
183
|
+
},
|
|
184
|
+
) =>
|
|
185
|
+
Effect.gen(function* () {
|
|
186
|
+
const { force = false, verbose = false } = options
|
|
187
|
+
|
|
188
|
+
// Try pushing without force first
|
|
189
|
+
const pushResult = yield* spawnProcess(
|
|
190
|
+
["git", "push", "-u", remote, branchName],
|
|
191
|
+
{
|
|
192
|
+
cwd: gitRoot,
|
|
193
|
+
stdout: verbose ? "inherit" : "pipe",
|
|
194
|
+
stderr: "pipe",
|
|
195
|
+
},
|
|
196
|
+
).pipe(
|
|
197
|
+
// Don't fail immediately - we need to check the error type
|
|
198
|
+
Effect.catchAll((error) =>
|
|
199
|
+
Effect.succeed({
|
|
200
|
+
exitCode: error.exitCode,
|
|
201
|
+
stdout: "",
|
|
202
|
+
stderr: error.stderr,
|
|
203
|
+
}),
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
let usedForce = false
|
|
208
|
+
|
|
209
|
+
// If push failed, check if we should retry with --force
|
|
210
|
+
if (pushResult.exitCode !== 0) {
|
|
211
|
+
const stderr = pushResult.stderr
|
|
212
|
+
|
|
213
|
+
// Check if this is a force-push-needed error
|
|
214
|
+
const needsForce =
|
|
215
|
+
stderr.includes("rejected") ||
|
|
216
|
+
stderr.includes("non-fast-forward") ||
|
|
217
|
+
stderr.includes("fetch first") ||
|
|
218
|
+
stderr.includes("Updates were rejected")
|
|
219
|
+
|
|
220
|
+
if (needsForce && force) {
|
|
221
|
+
// User provided --force flag, retry with force
|
|
222
|
+
const forceResult = yield* spawnProcess(
|
|
223
|
+
["git", "push", "-u", "--force", remote, branchName],
|
|
224
|
+
{
|
|
225
|
+
cwd: gitRoot,
|
|
226
|
+
stdout: verbose ? "inherit" : "pipe",
|
|
227
|
+
stderr: "pipe",
|
|
228
|
+
},
|
|
229
|
+
).pipe(
|
|
230
|
+
Effect.catchAll((error) =>
|
|
231
|
+
Effect.succeed({
|
|
232
|
+
exitCode: error.exitCode,
|
|
233
|
+
stdout: "",
|
|
234
|
+
stderr: error.stderr,
|
|
235
|
+
}),
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if (forceResult.exitCode !== 0) {
|
|
240
|
+
return yield* Effect.fail(
|
|
241
|
+
new Error(
|
|
242
|
+
`Failed to force push branch to remote: ${forceResult.stderr}`,
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
usedForce = true
|
|
248
|
+
} else if (needsForce && !force) {
|
|
249
|
+
// User didn't provide --force but it's needed
|
|
250
|
+
return yield* Effect.fail(
|
|
251
|
+
new Error(
|
|
252
|
+
`Failed to push branch to remote. The branch has diverged from the remote.\n` +
|
|
253
|
+
`Run 'agency push --force' to force push the branch.`,
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
} else {
|
|
257
|
+
// Some other error
|
|
258
|
+
return yield* Effect.fail(
|
|
259
|
+
new Error(`Failed to push branch to remote: ${stderr}`),
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return usedForce
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// Helper: Open GitHub PR using gh CLI
|
|
268
|
+
const openGitHubPR = (
|
|
269
|
+
gitRoot: string,
|
|
270
|
+
branchName: string,
|
|
271
|
+
options: {
|
|
272
|
+
readonly verbose?: boolean
|
|
273
|
+
},
|
|
274
|
+
) =>
|
|
275
|
+
Effect.gen(function* () {
|
|
276
|
+
const { verbose = false } = options
|
|
277
|
+
|
|
278
|
+
// Run gh pr create --web with --head to specify the emit branch
|
|
279
|
+
const ghResult = yield* spawnProcess(
|
|
280
|
+
["gh", "pr", "create", "--web", "--head", branchName],
|
|
281
|
+
{
|
|
282
|
+
cwd: gitRoot,
|
|
283
|
+
stdout: verbose ? "inherit" : "pipe",
|
|
284
|
+
stderr: "pipe",
|
|
285
|
+
},
|
|
286
|
+
).pipe(
|
|
287
|
+
Effect.catchAll((error) =>
|
|
288
|
+
Effect.succeed({
|
|
289
|
+
exitCode: error.exitCode,
|
|
290
|
+
stdout: "",
|
|
291
|
+
stderr: error.stderr,
|
|
292
|
+
}),
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if (ghResult.exitCode !== 0) {
|
|
297
|
+
return yield* Effect.fail(
|
|
298
|
+
new Error(`gh CLI command failed: ${ghResult.stderr.trim()}`),
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
export const help = `
|
|
304
|
+
Usage: agency push [base-branch] [options]
|
|
305
|
+
|
|
306
|
+
Create a emit branch, push it to remote, and return to the source branch.
|
|
307
|
+
|
|
308
|
+
This command is a convenience wrapper that runs operations in sequence:
|
|
309
|
+
1. agency emit [base-branch] - Create emit branch with backpack files reverted
|
|
310
|
+
2. git push -u origin <pr-branch> - Push emit branch to remote
|
|
311
|
+
3. gh pr create --web (optional with --pr) - Open GitHub PR in browser
|
|
312
|
+
4. git checkout <source-branch> - Switch back to source branch
|
|
313
|
+
|
|
314
|
+
The command ensures you end up back on your source branch after pushing
|
|
315
|
+
the emit branch, making it easy to continue working locally while having
|
|
316
|
+
a clean emit branch ready on the remote.
|
|
317
|
+
|
|
318
|
+
Base Branch Selection:
|
|
319
|
+
Same as 'agency emit' - see 'agency emit --help' for details
|
|
320
|
+
|
|
321
|
+
Prerequisites:
|
|
322
|
+
- git-filter-repo must be installed: brew install git-filter-repo
|
|
323
|
+
- Remote 'origin' must be configured
|
|
324
|
+
|
|
325
|
+
Arguments:
|
|
326
|
+
base-branch Base branch to compare against (e.g., origin/main)
|
|
327
|
+
If not provided, will use saved config or auto-detect
|
|
328
|
+
|
|
329
|
+
Options:
|
|
330
|
+
-b, --branch Custom name for emit branch (defaults to pattern from config)
|
|
331
|
+
-f, --force Force push to remote if branch has diverged
|
|
332
|
+
--pr Open GitHub PR in browser after pushing (requires gh CLI)
|
|
333
|
+
|
|
334
|
+
Examples:
|
|
335
|
+
agency push # Create PR, push, return to source
|
|
336
|
+
agency push origin/main # Explicitly use origin/main as base
|
|
337
|
+
agency push --force # Force push if branch has diverged
|
|
338
|
+
agency push --pr # Push and open GitHub PR in browser
|
|
339
|
+
|
|
340
|
+
Notes:
|
|
341
|
+
- Must be run from a source branch (not a emit branch)
|
|
342
|
+
- Creates or recreates the emit branch
|
|
343
|
+
- Pushes with -u flag to set up tracking
|
|
344
|
+
- Automatically returns to source branch after pushing
|
|
345
|
+
- If any step fails, the command stops and reports the error
|
|
346
|
+
`
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { save } from "./save"
|
|
4
|
+
import { task } from "./task"
|
|
5
|
+
import {
|
|
6
|
+
createTempDir,
|
|
7
|
+
cleanupTempDir,
|
|
8
|
+
initGitRepo,
|
|
9
|
+
initAgency,
|
|
10
|
+
readFile,
|
|
11
|
+
runTestEffect,
|
|
12
|
+
} from "../test-utils"
|
|
13
|
+
|
|
14
|
+
describe("save command", () => {
|
|
15
|
+
let tempDir: string
|
|
16
|
+
let originalCwd: string
|
|
17
|
+
let originalConfigDir: string | undefined
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
tempDir = await createTempDir()
|
|
21
|
+
originalCwd = process.cwd()
|
|
22
|
+
originalConfigDir = process.env.AGENCY_CONFIG_DIR
|
|
23
|
+
// Use a temp config dir to avoid interference from user's actual config
|
|
24
|
+
process.env.AGENCY_CONFIG_DIR = await createTempDir()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
process.chdir(originalCwd)
|
|
29
|
+
if (originalConfigDir !== undefined) {
|
|
30
|
+
process.env.AGENCY_CONFIG_DIR = originalConfigDir
|
|
31
|
+
} else {
|
|
32
|
+
delete process.env.AGENCY_CONFIG_DIR
|
|
33
|
+
}
|
|
34
|
+
if (
|
|
35
|
+
process.env.AGENCY_CONFIG_DIR &&
|
|
36
|
+
process.env.AGENCY_CONFIG_DIR !== originalConfigDir
|
|
37
|
+
) {
|
|
38
|
+
await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
|
|
39
|
+
}
|
|
40
|
+
await cleanupTempDir(tempDir)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("saves specified files to template directory", async () => {
|
|
44
|
+
await initGitRepo(tempDir)
|
|
45
|
+
process.chdir(tempDir)
|
|
46
|
+
|
|
47
|
+
// Initialize with template
|
|
48
|
+
await initAgency(tempDir, "test-save")
|
|
49
|
+
await task({ silent: true, branch: "test-feature" })
|
|
50
|
+
|
|
51
|
+
// Modify the files
|
|
52
|
+
await Bun.write(join(tempDir, "AGENTS.md"), "# Modified content")
|
|
53
|
+
|
|
54
|
+
// Save specific files to template
|
|
55
|
+
await runTestEffect(save({ files: ["AGENTS.md"], silent: true }))
|
|
56
|
+
|
|
57
|
+
// Check template files were updated
|
|
58
|
+
const configDir = process.env.AGENCY_CONFIG_DIR!
|
|
59
|
+
const templateAgents = await readFile(
|
|
60
|
+
join(configDir, "templates", "test-save", "AGENTS.md"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
expect(templateAgents).toBe("# Modified content")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("throws error when no files specified", async () => {
|
|
67
|
+
await initGitRepo(tempDir)
|
|
68
|
+
process.chdir(tempDir)
|
|
69
|
+
|
|
70
|
+
// Initialize with template
|
|
71
|
+
await initAgency(tempDir, "test-no-files")
|
|
72
|
+
await task({
|
|
73
|
+
silent: true,
|
|
74
|
+
branch: "test-feature",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await expect(
|
|
78
|
+
runTestEffect(save({ files: [], silent: true })),
|
|
79
|
+
).rejects.toThrow("No files specified")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("throws error when no template configured", async () => {
|
|
83
|
+
await initGitRepo(tempDir)
|
|
84
|
+
process.chdir(tempDir)
|
|
85
|
+
|
|
86
|
+
await expect(
|
|
87
|
+
runTestEffect(save({ files: ["AGENTS.md"], silent: true })),
|
|
88
|
+
).rejects.toThrow("Repository not initialized")
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("throws error when not in git repo", async () => {
|
|
92
|
+
process.chdir(tempDir)
|
|
93
|
+
|
|
94
|
+
await expect(
|
|
95
|
+
runTestEffect(save({ files: ["AGENTS.md"], silent: true })),
|
|
96
|
+
).rejects.toThrow("Not in a git repository")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("skips files that don't exist", async () => {
|
|
100
|
+
await initGitRepo(tempDir)
|
|
101
|
+
process.chdir(tempDir)
|
|
102
|
+
|
|
103
|
+
// Initialize with template
|
|
104
|
+
await initAgency(tempDir, "test-partial")
|
|
105
|
+
await task({
|
|
106
|
+
silent: true,
|
|
107
|
+
branch: "test-feature",
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Remove AGENTS.md (just to test skipping behavior)
|
|
111
|
+
const rmProc = Bun.spawn(["rm", join(tempDir, "AGENTS.md")], {
|
|
112
|
+
cwd: tempDir,
|
|
113
|
+
stdout: "pipe",
|
|
114
|
+
stderr: "pipe",
|
|
115
|
+
})
|
|
116
|
+
await rmProc.exited
|
|
117
|
+
|
|
118
|
+
// Modify a different file that exists
|
|
119
|
+
await Bun.write(join(tempDir, "test.txt"), "# Test content")
|
|
120
|
+
|
|
121
|
+
// Save should succeed but skip the missing file
|
|
122
|
+
await runTestEffect(
|
|
123
|
+
save({ files: ["AGENTS.md", "test.txt"], silent: true }),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// Check test.txt was updated
|
|
127
|
+
const configDir = process.env.AGENCY_CONFIG_DIR!
|
|
128
|
+
const testContent = await readFile(
|
|
129
|
+
join(configDir, "templates", "test-partial", "test.txt"),
|
|
130
|
+
)
|
|
131
|
+
expect(testContent).toBe("# Test content")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("saves directories recursively", async () => {
|
|
135
|
+
await initGitRepo(tempDir)
|
|
136
|
+
process.chdir(tempDir)
|
|
137
|
+
|
|
138
|
+
// Initialize with template
|
|
139
|
+
await initAgency(tempDir, "test-dir")
|
|
140
|
+
await task({ silent: true, branch: "test-feature" })
|
|
141
|
+
|
|
142
|
+
// Create a directory with files
|
|
143
|
+
const docsDir = join(tempDir, "docs")
|
|
144
|
+
await Bun.write(join(docsDir, "README.md"), "# Documentation")
|
|
145
|
+
await Bun.write(join(docsDir, "guide.md"), "# Guide")
|
|
146
|
+
|
|
147
|
+
// Save the directory
|
|
148
|
+
await runTestEffect(save({ files: ["docs"], silent: true }))
|
|
149
|
+
|
|
150
|
+
// Check files were saved to template
|
|
151
|
+
const configDir = process.env.AGENCY_CONFIG_DIR!
|
|
152
|
+
const readmeContent = await readFile(
|
|
153
|
+
join(configDir, "templates", "test-dir", "docs", "README.md"),
|
|
154
|
+
)
|
|
155
|
+
const guideContent = await readFile(
|
|
156
|
+
join(configDir, "templates", "test-dir", "docs", "guide.md"),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
expect(readmeContent).toBe("# Documentation")
|
|
160
|
+
expect(guideContent).toBe("# Guide")
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test("refuses to save TASK.md", async () => {
|
|
164
|
+
await initGitRepo(tempDir)
|
|
165
|
+
process.chdir(tempDir)
|
|
166
|
+
|
|
167
|
+
// Initialize with template
|
|
168
|
+
await initAgency(tempDir, "test-task")
|
|
169
|
+
await task({ silent: true, branch: "test-feature" })
|
|
170
|
+
|
|
171
|
+
// Create a TASK.md with the {task} placeholder
|
|
172
|
+
await Bun.write(join(tempDir, "TASK.md"), "{task}\n\n## Notes")
|
|
173
|
+
|
|
174
|
+
// Save should fail regardless of content
|
|
175
|
+
await expect(
|
|
176
|
+
runTestEffect(save({ files: ["TASK.md"], silent: true })),
|
|
177
|
+
).rejects.toThrow("TASK.md files cannot be saved to templates")
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test("refuses to save TASK.md even without {task} placeholder", async () => {
|
|
181
|
+
await initGitRepo(tempDir)
|
|
182
|
+
process.chdir(tempDir)
|
|
183
|
+
|
|
184
|
+
// Initialize with template
|
|
185
|
+
await initAgency(tempDir, "test-task-invalid")
|
|
186
|
+
await task({
|
|
187
|
+
silent: true,
|
|
188
|
+
branch: "test-feature",
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Create a TASK.md without the {task} placeholder
|
|
192
|
+
await Bun.write(
|
|
193
|
+
join(tempDir, "TASK.md"),
|
|
194
|
+
"# Specific Task\n\nThis is a specific task, not a template",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
// Save should fail
|
|
198
|
+
await expect(
|
|
199
|
+
runTestEffect(save({ files: ["TASK.md"], silent: true })),
|
|
200
|
+
).rejects.toThrow("TASK.md files cannot be saved to templates")
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test("refuses to save TASK.md in subdirectories", async () => {
|
|
204
|
+
await initGitRepo(tempDir)
|
|
205
|
+
process.chdir(tempDir)
|
|
206
|
+
|
|
207
|
+
// Initialize with template
|
|
208
|
+
await initAgency(tempDir, "test-task-subdir")
|
|
209
|
+
await task({
|
|
210
|
+
silent: true,
|
|
211
|
+
branch: "test-feature",
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// Create a TASK.md in a subdirectory with the {task} placeholder
|
|
215
|
+
const subdir = join(tempDir, "projects")
|
|
216
|
+
await Bun.write(join(subdir, "TASK.md"), "{task}\n\n## Details")
|
|
217
|
+
|
|
218
|
+
// Save should fail regardless of content
|
|
219
|
+
await expect(
|
|
220
|
+
runTestEffect(save({ files: ["projects/TASK.md"], silent: true })),
|
|
221
|
+
).rejects.toThrow("TASK.md files cannot be saved to templates")
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test("refuses to save TASK.md in subdirectory even without {task} placeholder", async () => {
|
|
225
|
+
await initGitRepo(tempDir)
|
|
226
|
+
process.chdir(tempDir)
|
|
227
|
+
|
|
228
|
+
// Initialize with template
|
|
229
|
+
await initAgency(tempDir, "test-task-subdir-invalid")
|
|
230
|
+
await task({
|
|
231
|
+
silent: true,
|
|
232
|
+
branch: "test-feature",
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// Create a TASK.md in a subdirectory without the {task} placeholder
|
|
236
|
+
const subdir = join(tempDir, "projects")
|
|
237
|
+
await Bun.write(
|
|
238
|
+
join(subdir, "TASK.md"),
|
|
239
|
+
"# Specific Project Task\n\nThis is specific",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
// Save should fail
|
|
243
|
+
await expect(
|
|
244
|
+
runTestEffect(save({ files: ["projects/TASK.md"], silent: true })),
|
|
245
|
+
).rejects.toThrow("TASK.md files cannot be saved to templates")
|
|
246
|
+
})
|
|
247
|
+
})
|