@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,872 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { task } from "../commands/task"
|
|
4
|
+
import {
|
|
5
|
+
createTempDir,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
initGitRepo,
|
|
8
|
+
initAgency,
|
|
9
|
+
createSubdir,
|
|
10
|
+
fileExists,
|
|
11
|
+
readFile,
|
|
12
|
+
runTestEffect,
|
|
13
|
+
} from "../test-utils"
|
|
14
|
+
|
|
15
|
+
describe("task command", () => {
|
|
16
|
+
let tempDir: string
|
|
17
|
+
let originalCwd: string
|
|
18
|
+
let originalConfigDir: string | undefined
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tempDir = await createTempDir()
|
|
22
|
+
originalCwd = process.cwd()
|
|
23
|
+
originalConfigDir = process.env.AGENCY_CONFIG_DIR
|
|
24
|
+
// Use a temp config dir to avoid interference from user's actual config
|
|
25
|
+
process.env.AGENCY_CONFIG_DIR = await createTempDir()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
process.chdir(originalCwd)
|
|
30
|
+
if (originalConfigDir !== undefined) {
|
|
31
|
+
process.env.AGENCY_CONFIG_DIR = originalConfigDir
|
|
32
|
+
} else {
|
|
33
|
+
delete process.env.AGENCY_CONFIG_DIR
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
process.env.AGENCY_CONFIG_DIR &&
|
|
37
|
+
process.env.AGENCY_CONFIG_DIR !== originalConfigDir
|
|
38
|
+
) {
|
|
39
|
+
await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
|
|
40
|
+
}
|
|
41
|
+
await cleanupTempDir(tempDir)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("without path argument", () => {
|
|
45
|
+
test("creates AGENTS.md at git root", async () => {
|
|
46
|
+
await initGitRepo(tempDir)
|
|
47
|
+
process.chdir(tempDir)
|
|
48
|
+
|
|
49
|
+
await initAgency(tempDir, "test")
|
|
50
|
+
|
|
51
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
52
|
+
|
|
53
|
+
expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("creates AGENTS.md with default content", async () => {
|
|
57
|
+
await initGitRepo(tempDir)
|
|
58
|
+
process.chdir(tempDir)
|
|
59
|
+
|
|
60
|
+
await initAgency(tempDir, "test")
|
|
61
|
+
|
|
62
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
63
|
+
|
|
64
|
+
const content = await readFile(join(tempDir, "AGENTS.md"))
|
|
65
|
+
expect(content).toContain("Repo Instructions")
|
|
66
|
+
expect(content).toContain("AGENCY.md")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("creates file at git root even when run from subdirectory", async () => {
|
|
70
|
+
await initGitRepo(tempDir)
|
|
71
|
+
const subdir = await createSubdir(tempDir, "subdir")
|
|
72
|
+
process.chdir(subdir)
|
|
73
|
+
|
|
74
|
+
await initAgency(tempDir, "test")
|
|
75
|
+
|
|
76
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
77
|
+
|
|
78
|
+
expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
|
|
79
|
+
expect(await fileExists(join(subdir, "AGENTS.md"))).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("does not overwrite existing AGENTS.md", async () => {
|
|
83
|
+
await initGitRepo(tempDir)
|
|
84
|
+
process.chdir(tempDir)
|
|
85
|
+
|
|
86
|
+
const existingContent = "# Existing content"
|
|
87
|
+
await Bun.write(join(tempDir, "AGENTS.md"), existingContent)
|
|
88
|
+
|
|
89
|
+
await initAgency(tempDir, "test")
|
|
90
|
+
|
|
91
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
92
|
+
|
|
93
|
+
const content = await readFile(join(tempDir, "AGENTS.md"))
|
|
94
|
+
expect(content).toBe(existingContent)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("throws error when not in a git repository", async () => {
|
|
98
|
+
process.chdir(tempDir)
|
|
99
|
+
|
|
100
|
+
await expect(runTestEffect(task({ silent: true }))).rejects.toThrow(
|
|
101
|
+
"Not in a git repository",
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("with path argument", () => {
|
|
107
|
+
test("creates file at specified git root", async () => {
|
|
108
|
+
await initGitRepo(tempDir)
|
|
109
|
+
|
|
110
|
+
await initAgency(tempDir, "test")
|
|
111
|
+
await runTestEffect(
|
|
112
|
+
task({
|
|
113
|
+
path: tempDir,
|
|
114
|
+
silent: true,
|
|
115
|
+
branch: "test-feature",
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test("throws error when path is not a git repository root", async () => {
|
|
123
|
+
await initGitRepo(tempDir)
|
|
124
|
+
const subdir = await createSubdir(tempDir, "subdir")
|
|
125
|
+
|
|
126
|
+
expect(
|
|
127
|
+
runTestEffect(task({ path: subdir, silent: true })),
|
|
128
|
+
).rejects.toThrow("not the root of a git repository")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("throws error when path is not a git repository at all", async () => {
|
|
132
|
+
expect(
|
|
133
|
+
runTestEffect(task({ path: tempDir, silent: true })),
|
|
134
|
+
).rejects.toThrow("not the root of a git repository")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("resolves relative paths correctly", async () => {
|
|
138
|
+
await initGitRepo(tempDir)
|
|
139
|
+
const subdir = await createSubdir(tempDir, "subdir")
|
|
140
|
+
process.chdir(subdir)
|
|
141
|
+
|
|
142
|
+
await initAgency(tempDir, "test")
|
|
143
|
+
await runTestEffect(
|
|
144
|
+
task({
|
|
145
|
+
path: "..",
|
|
146
|
+
silent: true,
|
|
147
|
+
branch: "test-feature",
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe("opencode.json support", () => {
|
|
156
|
+
test("creates opencode.json at git root", async () => {
|
|
157
|
+
await initGitRepo(tempDir)
|
|
158
|
+
process.chdir(tempDir)
|
|
159
|
+
|
|
160
|
+
await initAgency(tempDir, "test")
|
|
161
|
+
|
|
162
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
163
|
+
|
|
164
|
+
expect(await fileExists(join(tempDir, "opencode.json"))).toBe(true)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test("creates opencode.json with default content", async () => {
|
|
168
|
+
await initGitRepo(tempDir)
|
|
169
|
+
process.chdir(tempDir)
|
|
170
|
+
|
|
171
|
+
await initAgency(tempDir, "test")
|
|
172
|
+
|
|
173
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
174
|
+
|
|
175
|
+
const content = await readFile(join(tempDir, "opencode.json"))
|
|
176
|
+
const parsed = JSON.parse(content)
|
|
177
|
+
|
|
178
|
+
expect(parsed.$schema).toBe("https://opencode.ai/config.json")
|
|
179
|
+
expect(parsed.instructions).toEqual(["AGENCY.md", "TASK.md"])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("merges with existing opencode.json", async () => {
|
|
183
|
+
await initGitRepo(tempDir)
|
|
184
|
+
process.chdir(tempDir)
|
|
185
|
+
|
|
186
|
+
const existingConfig = {
|
|
187
|
+
custom: "config",
|
|
188
|
+
}
|
|
189
|
+
await Bun.write(
|
|
190
|
+
join(tempDir, "opencode.json"),
|
|
191
|
+
JSON.stringify(existingConfig),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
await initAgency(tempDir, "test")
|
|
195
|
+
|
|
196
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
197
|
+
|
|
198
|
+
const content = await readFile(join(tempDir, "opencode.json"))
|
|
199
|
+
const parsed = JSON.parse(content)
|
|
200
|
+
|
|
201
|
+
// Should preserve existing properties
|
|
202
|
+
expect(parsed.custom).toBe("config")
|
|
203
|
+
|
|
204
|
+
// Should add our instructions
|
|
205
|
+
expect(parsed.instructions).toContain("AGENCY.md")
|
|
206
|
+
expect(parsed.instructions).toContain("TASK.md")
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test("uses opencode.json from template directory if it exists", async () => {
|
|
210
|
+
await initGitRepo(tempDir)
|
|
211
|
+
process.chdir(tempDir)
|
|
212
|
+
|
|
213
|
+
const configDir = process.env.AGENCY_CONFIG_DIR!
|
|
214
|
+
const templateDir = join(configDir, "templates", "custom-template")
|
|
215
|
+
await Bun.spawn(["mkdir", "-p", templateDir], {
|
|
216
|
+
stdout: "pipe",
|
|
217
|
+
stderr: "pipe",
|
|
218
|
+
}).exited
|
|
219
|
+
|
|
220
|
+
const customConfig = JSON.stringify({
|
|
221
|
+
$schema: "https://opencode.ai/config.json",
|
|
222
|
+
instructions: ["CUSTOM.md"],
|
|
223
|
+
})
|
|
224
|
+
await Bun.write(join(templateDir, "opencode.json"), customConfig)
|
|
225
|
+
|
|
226
|
+
await initAgency(tempDir, "custom-template")
|
|
227
|
+
|
|
228
|
+
await runTestEffect(
|
|
229
|
+
task({
|
|
230
|
+
silent: true,
|
|
231
|
+
branch: "test-feature",
|
|
232
|
+
}),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
const content = await readFile(join(tempDir, "opencode.json"))
|
|
236
|
+
expect(content).toBe(customConfig)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test("merges with existing opencode.jsonc", async () => {
|
|
240
|
+
await initGitRepo(tempDir)
|
|
241
|
+
process.chdir(tempDir)
|
|
242
|
+
|
|
243
|
+
const existingConfig = {
|
|
244
|
+
custom: "config",
|
|
245
|
+
existing: "property",
|
|
246
|
+
}
|
|
247
|
+
await Bun.write(
|
|
248
|
+
join(tempDir, "opencode.jsonc"),
|
|
249
|
+
JSON.stringify(existingConfig, null, 2),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
await initAgency(tempDir, "test")
|
|
253
|
+
|
|
254
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
255
|
+
|
|
256
|
+
// Should update opencode.jsonc, not create opencode.json
|
|
257
|
+
expect(await fileExists(join(tempDir, "opencode.jsonc"))).toBe(true)
|
|
258
|
+
expect(await fileExists(join(tempDir, "opencode.json"))).toBe(false)
|
|
259
|
+
|
|
260
|
+
const content = await readFile(join(tempDir, "opencode.jsonc"))
|
|
261
|
+
const parsed = JSON.parse(content)
|
|
262
|
+
|
|
263
|
+
// Should preserve existing properties
|
|
264
|
+
expect(parsed.custom).toBe("config")
|
|
265
|
+
expect(parsed.existing).toBe("property")
|
|
266
|
+
|
|
267
|
+
// Should add our instructions
|
|
268
|
+
expect(parsed.instructions).toContain("AGENCY.md")
|
|
269
|
+
expect(parsed.instructions).toContain("TASK.md")
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test("prefers opencode.jsonc over opencode.json when both exist", async () => {
|
|
273
|
+
await initGitRepo(tempDir)
|
|
274
|
+
process.chdir(tempDir)
|
|
275
|
+
|
|
276
|
+
// Create both files
|
|
277
|
+
const jsoncConfig = {
|
|
278
|
+
custom: "jsonc-config",
|
|
279
|
+
}
|
|
280
|
+
const jsonConfig = {
|
|
281
|
+
custom: "json-config",
|
|
282
|
+
}
|
|
283
|
+
await Bun.write(
|
|
284
|
+
join(tempDir, "opencode.jsonc"),
|
|
285
|
+
JSON.stringify(jsoncConfig, null, 2),
|
|
286
|
+
)
|
|
287
|
+
await Bun.write(
|
|
288
|
+
join(tempDir, "opencode.json"),
|
|
289
|
+
JSON.stringify(jsonConfig, null, 2),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
await initAgency(tempDir, "test")
|
|
293
|
+
|
|
294
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
295
|
+
|
|
296
|
+
// Should merge with opencode.jsonc (not opencode.json)
|
|
297
|
+
const jsoncContent = await readFile(join(tempDir, "opencode.jsonc"))
|
|
298
|
+
const jsoncParsed = JSON.parse(jsoncContent)
|
|
299
|
+
|
|
300
|
+
expect(jsoncParsed.custom).toBe("jsonc-config")
|
|
301
|
+
expect(jsoncParsed.instructions).toContain("AGENCY.md")
|
|
302
|
+
|
|
303
|
+
// opencode.json should remain unchanged
|
|
304
|
+
const jsonContent = await readFile(join(tempDir, "opencode.json"))
|
|
305
|
+
expect(jsonContent).toBe(JSON.stringify(jsonConfig, null, 2))
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test("handles opencode.jsonc with comments", async () => {
|
|
309
|
+
await initGitRepo(tempDir)
|
|
310
|
+
process.chdir(tempDir)
|
|
311
|
+
|
|
312
|
+
const jsoncWithComments = `{
|
|
313
|
+
// This is a comment
|
|
314
|
+
"custom": "config",
|
|
315
|
+
/* Multi-line
|
|
316
|
+
comment */
|
|
317
|
+
"existing": "property"
|
|
318
|
+
}`
|
|
319
|
+
await Bun.write(join(tempDir, "opencode.jsonc"), jsoncWithComments)
|
|
320
|
+
|
|
321
|
+
await initAgency(tempDir, "test")
|
|
322
|
+
|
|
323
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
324
|
+
|
|
325
|
+
const content = await readFile(join(tempDir, "opencode.jsonc"))
|
|
326
|
+
const parsed = JSON.parse(content)
|
|
327
|
+
|
|
328
|
+
// Should preserve existing properties
|
|
329
|
+
expect(parsed.custom).toBe("config")
|
|
330
|
+
expect(parsed.existing).toBe("property")
|
|
331
|
+
|
|
332
|
+
// Should add our instructions
|
|
333
|
+
expect(parsed.instructions).toContain("AGENCY.md")
|
|
334
|
+
expect(parsed.instructions).toContain("TASK.md")
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test("merges with .opencode/opencode.json", async () => {
|
|
338
|
+
await initGitRepo(tempDir)
|
|
339
|
+
process.chdir(tempDir)
|
|
340
|
+
|
|
341
|
+
// Create .opencode directory with opencode.json
|
|
342
|
+
const dotOpencodeDir = join(tempDir, ".opencode")
|
|
343
|
+
await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
|
|
344
|
+
stdout: "pipe",
|
|
345
|
+
stderr: "pipe",
|
|
346
|
+
}).exited
|
|
347
|
+
|
|
348
|
+
const existingConfig = {
|
|
349
|
+
custom: "config-in-dotdir",
|
|
350
|
+
}
|
|
351
|
+
await Bun.write(
|
|
352
|
+
join(dotOpencodeDir, "opencode.json"),
|
|
353
|
+
JSON.stringify(existingConfig, null, 2),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
await initAgency(tempDir, "test")
|
|
357
|
+
|
|
358
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
359
|
+
|
|
360
|
+
// Should update .opencode/opencode.json, not create root opencode.json
|
|
361
|
+
expect(await fileExists(join(dotOpencodeDir, "opencode.json"))).toBe(true)
|
|
362
|
+
expect(await fileExists(join(tempDir, "opencode.json"))).toBe(false)
|
|
363
|
+
|
|
364
|
+
const content = await readFile(join(dotOpencodeDir, "opencode.json"))
|
|
365
|
+
const parsed = JSON.parse(content)
|
|
366
|
+
|
|
367
|
+
// Should preserve existing properties
|
|
368
|
+
expect(parsed.custom).toBe("config-in-dotdir")
|
|
369
|
+
|
|
370
|
+
// Should add our instructions
|
|
371
|
+
expect(parsed.instructions).toContain("AGENCY.md")
|
|
372
|
+
expect(parsed.instructions).toContain("TASK.md")
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test("merges with .opencode/opencode.jsonc", async () => {
|
|
376
|
+
await initGitRepo(tempDir)
|
|
377
|
+
process.chdir(tempDir)
|
|
378
|
+
|
|
379
|
+
// Create .opencode directory with opencode.jsonc
|
|
380
|
+
const dotOpencodeDir = join(tempDir, ".opencode")
|
|
381
|
+
await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
|
|
382
|
+
stdout: "pipe",
|
|
383
|
+
stderr: "pipe",
|
|
384
|
+
}).exited
|
|
385
|
+
|
|
386
|
+
const existingConfig = {
|
|
387
|
+
custom: "jsonc-in-dotdir",
|
|
388
|
+
}
|
|
389
|
+
await Bun.write(
|
|
390
|
+
join(dotOpencodeDir, "opencode.jsonc"),
|
|
391
|
+
JSON.stringify(existingConfig, null, 2),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
await initAgency(tempDir, "test")
|
|
395
|
+
|
|
396
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
397
|
+
|
|
398
|
+
// Should update .opencode/opencode.jsonc, not create root opencode.json
|
|
399
|
+
expect(await fileExists(join(dotOpencodeDir, "opencode.jsonc"))).toBe(
|
|
400
|
+
true,
|
|
401
|
+
)
|
|
402
|
+
expect(await fileExists(join(tempDir, "opencode.json"))).toBe(false)
|
|
403
|
+
|
|
404
|
+
const content = await readFile(join(dotOpencodeDir, "opencode.jsonc"))
|
|
405
|
+
const parsed = JSON.parse(content)
|
|
406
|
+
|
|
407
|
+
// Should preserve existing properties
|
|
408
|
+
expect(parsed.custom).toBe("jsonc-in-dotdir")
|
|
409
|
+
|
|
410
|
+
// Should add our instructions
|
|
411
|
+
expect(parsed.instructions).toContain("AGENCY.md")
|
|
412
|
+
expect(parsed.instructions).toContain("TASK.md")
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test("prefers .opencode/opencode.jsonc over root opencode.json", async () => {
|
|
416
|
+
await initGitRepo(tempDir)
|
|
417
|
+
process.chdir(tempDir)
|
|
418
|
+
|
|
419
|
+
// Create .opencode directory with opencode.jsonc
|
|
420
|
+
const dotOpencodeDir = join(tempDir, ".opencode")
|
|
421
|
+
await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
|
|
422
|
+
stdout: "pipe",
|
|
423
|
+
stderr: "pipe",
|
|
424
|
+
}).exited
|
|
425
|
+
|
|
426
|
+
const dotOpencodeConfig = {
|
|
427
|
+
custom: "dotdir-jsonc",
|
|
428
|
+
}
|
|
429
|
+
const rootJsonConfig = {
|
|
430
|
+
custom: "root-json",
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
await Bun.write(
|
|
434
|
+
join(dotOpencodeDir, "opencode.jsonc"),
|
|
435
|
+
JSON.stringify(dotOpencodeConfig, null, 2),
|
|
436
|
+
)
|
|
437
|
+
await Bun.write(
|
|
438
|
+
join(tempDir, "opencode.json"),
|
|
439
|
+
JSON.stringify(rootJsonConfig, null, 2),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
await initAgency(tempDir, "test")
|
|
443
|
+
|
|
444
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
445
|
+
|
|
446
|
+
// Should merge with .opencode/opencode.jsonc (not root opencode.json)
|
|
447
|
+
const dotDirContent = await readFile(
|
|
448
|
+
join(dotOpencodeDir, "opencode.jsonc"),
|
|
449
|
+
)
|
|
450
|
+
const dotDirParsed = JSON.parse(dotDirContent)
|
|
451
|
+
|
|
452
|
+
expect(dotDirParsed.custom).toBe("dotdir-jsonc")
|
|
453
|
+
expect(dotDirParsed.instructions).toContain("AGENCY.md")
|
|
454
|
+
|
|
455
|
+
// root opencode.json should remain unchanged
|
|
456
|
+
const rootContent = await readFile(join(tempDir, "opencode.json"))
|
|
457
|
+
expect(rootContent).toBe(JSON.stringify(rootJsonConfig, null, 2))
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test("prefers .opencode/opencode.json over root opencode.jsonc", async () => {
|
|
461
|
+
await initGitRepo(tempDir)
|
|
462
|
+
process.chdir(tempDir)
|
|
463
|
+
|
|
464
|
+
// Create .opencode directory with opencode.json
|
|
465
|
+
const dotOpencodeDir = join(tempDir, ".opencode")
|
|
466
|
+
await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
|
|
467
|
+
stdout: "pipe",
|
|
468
|
+
stderr: "pipe",
|
|
469
|
+
}).exited
|
|
470
|
+
|
|
471
|
+
const dotOpencodeConfig = {
|
|
472
|
+
custom: "dotdir-json",
|
|
473
|
+
}
|
|
474
|
+
const rootJsoncConfig = {
|
|
475
|
+
custom: "root-jsonc",
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
await Bun.write(
|
|
479
|
+
join(dotOpencodeDir, "opencode.json"),
|
|
480
|
+
JSON.stringify(dotOpencodeConfig, null, 2),
|
|
481
|
+
)
|
|
482
|
+
await Bun.write(
|
|
483
|
+
join(tempDir, "opencode.jsonc"),
|
|
484
|
+
JSON.stringify(rootJsoncConfig, null, 2),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
await initAgency(tempDir, "test")
|
|
488
|
+
|
|
489
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
490
|
+
|
|
491
|
+
// Should merge with .opencode/opencode.json (not root opencode.jsonc)
|
|
492
|
+
const dotDirContent = await readFile(
|
|
493
|
+
join(dotOpencodeDir, "opencode.json"),
|
|
494
|
+
)
|
|
495
|
+
const dotDirParsed = JSON.parse(dotDirContent)
|
|
496
|
+
|
|
497
|
+
expect(dotDirParsed.custom).toBe("dotdir-json")
|
|
498
|
+
expect(dotDirParsed.instructions).toContain("AGENCY.md")
|
|
499
|
+
|
|
500
|
+
// root opencode.jsonc should remain unchanged
|
|
501
|
+
const rootContent = await readFile(join(tempDir, "opencode.jsonc"))
|
|
502
|
+
expect(rootContent).toBe(JSON.stringify(rootJsoncConfig, null, 2))
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
test("adds .opencode/opencode.json to injectedFiles", async () => {
|
|
506
|
+
await initGitRepo(tempDir)
|
|
507
|
+
process.chdir(tempDir)
|
|
508
|
+
|
|
509
|
+
// Create .opencode directory with opencode.json
|
|
510
|
+
const dotOpencodeDir = join(tempDir, ".opencode")
|
|
511
|
+
await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
|
|
512
|
+
stdout: "pipe",
|
|
513
|
+
stderr: "pipe",
|
|
514
|
+
}).exited
|
|
515
|
+
|
|
516
|
+
await Bun.write(
|
|
517
|
+
join(dotOpencodeDir, "opencode.json"),
|
|
518
|
+
JSON.stringify({ custom: "config" }, null, 2),
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
await initAgency(tempDir, "test")
|
|
522
|
+
|
|
523
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
524
|
+
|
|
525
|
+
// Check agency.json to verify .opencode/opencode.json is in injectedFiles
|
|
526
|
+
const agencyJsonContent = await readFile(join(tempDir, "agency.json"))
|
|
527
|
+
const metadata = JSON.parse(agencyJsonContent)
|
|
528
|
+
|
|
529
|
+
expect(metadata.injectedFiles).toContain(".opencode/opencode.json")
|
|
530
|
+
})
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
describe("TASK.md support", () => {
|
|
534
|
+
test("creates TASK.md at git root", async () => {
|
|
535
|
+
await initGitRepo(tempDir)
|
|
536
|
+
process.chdir(tempDir)
|
|
537
|
+
|
|
538
|
+
await initAgency(tempDir, "test")
|
|
539
|
+
|
|
540
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
541
|
+
|
|
542
|
+
expect(await fileExists(join(tempDir, "TASK.md"))).toBe(true)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test("creates TASK.md with placeholder when no task provided", async () => {
|
|
546
|
+
await initGitRepo(tempDir)
|
|
547
|
+
process.chdir(tempDir)
|
|
548
|
+
|
|
549
|
+
await initAgency(tempDir, "test")
|
|
550
|
+
|
|
551
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
552
|
+
|
|
553
|
+
const content = await readFile(join(tempDir, "TASK.md"))
|
|
554
|
+
expect(content).toContain("{task}")
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test("creates TASK.md with task description when provided", async () => {
|
|
558
|
+
await initGitRepo(tempDir)
|
|
559
|
+
process.chdir(tempDir)
|
|
560
|
+
|
|
561
|
+
await initAgency(tempDir, "test")
|
|
562
|
+
await runTestEffect(
|
|
563
|
+
task({
|
|
564
|
+
silent: true,
|
|
565
|
+
task: "Build new feature",
|
|
566
|
+
branch: "test-feature",
|
|
567
|
+
}),
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
const content = await readFile(join(tempDir, "TASK.md"))
|
|
571
|
+
expect(content).toContain("Build new feature")
|
|
572
|
+
expect(content).not.toContain("{task}")
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
test("skips TASK.md if it already exists", async () => {
|
|
576
|
+
await initGitRepo(tempDir)
|
|
577
|
+
process.chdir(tempDir)
|
|
578
|
+
|
|
579
|
+
const existingContent = "# Existing TASK"
|
|
580
|
+
await Bun.write(join(tempDir, "TASK.md"), existingContent)
|
|
581
|
+
|
|
582
|
+
await initAgency(tempDir, "test")
|
|
583
|
+
|
|
584
|
+
// Should succeed but skip TASK.md
|
|
585
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
586
|
+
|
|
587
|
+
// TASK.md should not be overwritten
|
|
588
|
+
const content = await readFile(join(tempDir, "TASK.md"))
|
|
589
|
+
expect(content).toBe(existingContent)
|
|
590
|
+
|
|
591
|
+
// Other files should still be created
|
|
592
|
+
expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
test("uses TASK.md from template directory if it exists", async () => {
|
|
596
|
+
await initGitRepo(tempDir)
|
|
597
|
+
process.chdir(tempDir)
|
|
598
|
+
|
|
599
|
+
const configDir = process.env.AGENCY_CONFIG_DIR!
|
|
600
|
+
const templateDir = join(configDir, "templates", "custom-template")
|
|
601
|
+
await Bun.spawn(["mkdir", "-p", templateDir], {
|
|
602
|
+
stdout: "pipe",
|
|
603
|
+
stderr: "pipe",
|
|
604
|
+
}).exited
|
|
605
|
+
|
|
606
|
+
const customTask = "# Custom Task Content"
|
|
607
|
+
await Bun.write(join(templateDir, "TASK.md"), customTask)
|
|
608
|
+
|
|
609
|
+
await initAgency(tempDir, "custom-template")
|
|
610
|
+
|
|
611
|
+
await runTestEffect(
|
|
612
|
+
task({
|
|
613
|
+
silent: true,
|
|
614
|
+
branch: "test-feature",
|
|
615
|
+
}),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
const content = await readFile(join(tempDir, "TASK.md"))
|
|
619
|
+
expect(content).toBe(customTask)
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
describe("silent mode", () => {
|
|
624
|
+
test("silent flag suppresses output", async () => {
|
|
625
|
+
await initGitRepo(tempDir)
|
|
626
|
+
process.chdir(tempDir)
|
|
627
|
+
|
|
628
|
+
// Capture console.log calls
|
|
629
|
+
const logs: string[] = []
|
|
630
|
+
const originalLog = console.log
|
|
631
|
+
console.log = (...args: any[]) => logs.push(args.join(" "))
|
|
632
|
+
|
|
633
|
+
await initAgency(tempDir, "test")
|
|
634
|
+
|
|
635
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
636
|
+
|
|
637
|
+
console.log = originalLog
|
|
638
|
+
|
|
639
|
+
expect(logs.length).toBe(0)
|
|
640
|
+
expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
test("without silent flag produces output", async () => {
|
|
644
|
+
await initGitRepo(tempDir)
|
|
645
|
+
process.chdir(tempDir)
|
|
646
|
+
|
|
647
|
+
// Create a feature branch to avoid the main branch check
|
|
648
|
+
await Bun.spawn(["git", "checkout", "-b", "test-feature"], {
|
|
649
|
+
cwd: tempDir,
|
|
650
|
+
stdout: "pipe",
|
|
651
|
+
stderr: "pipe",
|
|
652
|
+
}).exited
|
|
653
|
+
|
|
654
|
+
// Capture console.log calls
|
|
655
|
+
const logs: string[] = []
|
|
656
|
+
const originalLog = console.log
|
|
657
|
+
console.log = (...args: any[]) => logs.push(args.join(" "))
|
|
658
|
+
|
|
659
|
+
// Provide task to avoid interactive prompt
|
|
660
|
+
await initAgency(tempDir, "test")
|
|
661
|
+
await runTestEffect(
|
|
662
|
+
task({
|
|
663
|
+
silent: false,
|
|
664
|
+
task: "Test task",
|
|
665
|
+
branch: "test-feature",
|
|
666
|
+
}),
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
console.log = originalLog
|
|
670
|
+
|
|
671
|
+
expect(logs.length).toBeGreaterThan(0)
|
|
672
|
+
expect(logs.some((log) => log.includes("Created"))).toBe(true)
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
describe("branch handling", () => {
|
|
677
|
+
test("fails when on main branch without branch name", async () => {
|
|
678
|
+
await initGitRepo(tempDir)
|
|
679
|
+
process.chdir(tempDir)
|
|
680
|
+
|
|
681
|
+
await initAgency(tempDir, "test")
|
|
682
|
+
await expect(runTestEffect(task({ silent: true }))).rejects.toThrow(
|
|
683
|
+
"main branch",
|
|
684
|
+
)
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
test("creates branch when on main branch with branch name provided", async () => {
|
|
688
|
+
await initGitRepo(tempDir)
|
|
689
|
+
process.chdir(tempDir)
|
|
690
|
+
|
|
691
|
+
await initAgency(tempDir, "test")
|
|
692
|
+
|
|
693
|
+
await runTestEffect(task({ silent: true, branch: "my-feature" }))
|
|
694
|
+
|
|
695
|
+
// Verify we're now on the new branch
|
|
696
|
+
const proc = Bun.spawn(["git", "branch", "--show-current"], {
|
|
697
|
+
cwd: tempDir,
|
|
698
|
+
stdout: "pipe",
|
|
699
|
+
stderr: "pipe",
|
|
700
|
+
})
|
|
701
|
+
await proc.exited
|
|
702
|
+
const currentBranch = await new Response(proc.stdout).text()
|
|
703
|
+
expect(currentBranch.trim()).toBe("agency/my-feature")
|
|
704
|
+
|
|
705
|
+
// Verify files were created
|
|
706
|
+
expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
test("fails early when branch already exists", async () => {
|
|
710
|
+
await initGitRepo(tempDir)
|
|
711
|
+
process.chdir(tempDir)
|
|
712
|
+
|
|
713
|
+
// Create a feature branch first (using source pattern)
|
|
714
|
+
await Bun.spawn(["git", "checkout", "-b", "agency/existing-branch"], {
|
|
715
|
+
cwd: tempDir,
|
|
716
|
+
stdout: "pipe",
|
|
717
|
+
stderr: "pipe",
|
|
718
|
+
}).exited
|
|
719
|
+
|
|
720
|
+
// Switch back to main
|
|
721
|
+
await Bun.spawn(["git", "checkout", "main"], {
|
|
722
|
+
cwd: tempDir,
|
|
723
|
+
stdout: "pipe",
|
|
724
|
+
stderr: "pipe",
|
|
725
|
+
}).exited
|
|
726
|
+
|
|
727
|
+
// Try to create a branch with the same name
|
|
728
|
+
await expect(
|
|
729
|
+
(async () => {
|
|
730
|
+
await initAgency(tempDir, "test")
|
|
731
|
+
return await runTestEffect(
|
|
732
|
+
task({
|
|
733
|
+
silent: true,
|
|
734
|
+
branch: "existing-branch",
|
|
735
|
+
task: "This should not be asked for",
|
|
736
|
+
}),
|
|
737
|
+
)
|
|
738
|
+
})(),
|
|
739
|
+
).rejects.toThrow("already exists")
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
test("succeeds when already on a feature branch", async () => {
|
|
743
|
+
await initGitRepo(tempDir)
|
|
744
|
+
process.chdir(tempDir)
|
|
745
|
+
|
|
746
|
+
// Create and switch to feature branch
|
|
747
|
+
await Bun.spawn(["git", "checkout", "-b", "feature-branch"], {
|
|
748
|
+
cwd: tempDir,
|
|
749
|
+
stdout: "pipe",
|
|
750
|
+
stderr: "pipe",
|
|
751
|
+
}).exited
|
|
752
|
+
|
|
753
|
+
// Should succeed without needing branch option
|
|
754
|
+
await initAgency(tempDir, "test")
|
|
755
|
+
|
|
756
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
757
|
+
|
|
758
|
+
expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
|
|
759
|
+
})
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
describe("template-based source files", () => {
|
|
763
|
+
let configDir: string
|
|
764
|
+
let originalEnv: string | undefined
|
|
765
|
+
|
|
766
|
+
beforeEach(async () => {
|
|
767
|
+
// Create a temporary config directory
|
|
768
|
+
configDir = await createTempDir()
|
|
769
|
+
originalEnv = process.env.AGENCY_CONFIG_DIR
|
|
770
|
+
process.env.AGENCY_CONFIG_DIR = configDir
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
afterEach(async () => {
|
|
774
|
+
// Restore original env
|
|
775
|
+
if (originalEnv !== undefined) {
|
|
776
|
+
process.env.AGENCY_CONFIG_DIR = originalEnv
|
|
777
|
+
} else {
|
|
778
|
+
delete process.env.AGENCY_CONFIG_DIR
|
|
779
|
+
}
|
|
780
|
+
await cleanupTempDir(configDir)
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
test("uses AGENTS.md from template directory if it exists", async () => {
|
|
784
|
+
await initGitRepo(tempDir)
|
|
785
|
+
process.chdir(tempDir)
|
|
786
|
+
|
|
787
|
+
// Create template with custom content
|
|
788
|
+
const templateDir = join(configDir, "templates", "custom")
|
|
789
|
+
await Bun.spawn(["mkdir", "-p", templateDir], {
|
|
790
|
+
stdout: "pipe",
|
|
791
|
+
stderr: "pipe",
|
|
792
|
+
}).exited
|
|
793
|
+
|
|
794
|
+
const sourceContent = "# Custom AGENTS.md content\nThis is from template"
|
|
795
|
+
await Bun.write(join(templateDir, "AGENTS.md"), sourceContent)
|
|
796
|
+
|
|
797
|
+
await initAgency(tempDir, "custom")
|
|
798
|
+
|
|
799
|
+
await runTestEffect(task({ silent: true, branch: "test-feature" }))
|
|
800
|
+
|
|
801
|
+
const content = await readFile(join(tempDir, "AGENTS.md"))
|
|
802
|
+
expect(content).toBe(sourceContent)
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
test("uses default content when template doesn't exist yet", async () => {
|
|
806
|
+
await initGitRepo(tempDir)
|
|
807
|
+
process.chdir(tempDir)
|
|
808
|
+
|
|
809
|
+
await initAgency(tempDir, "custom-template")
|
|
810
|
+
|
|
811
|
+
await runTestEffect(
|
|
812
|
+
task({
|
|
813
|
+
silent: true,
|
|
814
|
+
branch: "test-feature",
|
|
815
|
+
}),
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
const agentsContent = await readFile(join(tempDir, "AGENTS.md"))
|
|
819
|
+
|
|
820
|
+
expect(agentsContent).toContain("Repo Instructions")
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
test("does not populate template directory automatically on first use", async () => {
|
|
824
|
+
await initGitRepo(tempDir)
|
|
825
|
+
process.chdir(tempDir)
|
|
826
|
+
|
|
827
|
+
await initAgency(tempDir, "auto-created")
|
|
828
|
+
|
|
829
|
+
await runTestEffect(
|
|
830
|
+
task({
|
|
831
|
+
silent: true,
|
|
832
|
+
branch: "test-feature",
|
|
833
|
+
}),
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
// Template should NOT have AGENTS.md (not populated automatically)
|
|
837
|
+
const templateDir = join(configDir, "templates", "auto-created")
|
|
838
|
+
const templateAgentsExists = await fileExists(
|
|
839
|
+
join(templateDir, "AGENTS.md"),
|
|
840
|
+
)
|
|
841
|
+
expect(templateAgentsExists).toBe(false)
|
|
842
|
+
|
|
843
|
+
// Files in repo should use default content (not from template)
|
|
844
|
+
const agentsContent = await readFile(join(tempDir, "AGENTS.md"))
|
|
845
|
+
expect(agentsContent).toContain("Repo Instructions")
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
test("excludes AGENCY.md and TASK.md from injectedFiles metadata", async () => {
|
|
849
|
+
await initGitRepo(tempDir)
|
|
850
|
+
process.chdir(tempDir)
|
|
851
|
+
|
|
852
|
+
await initAgency(tempDir, "test")
|
|
853
|
+
|
|
854
|
+
await runTestEffect(
|
|
855
|
+
task({
|
|
856
|
+
silent: true,
|
|
857
|
+
branch: "test-feature",
|
|
858
|
+
}),
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
// Read agency.json metadata
|
|
862
|
+
const metadata = JSON.parse(await readFile(join(tempDir, "agency.json")))
|
|
863
|
+
|
|
864
|
+
// AGENCY.md and TASK.md should NOT be in injectedFiles
|
|
865
|
+
expect(metadata.injectedFiles).not.toContain("AGENCY.md")
|
|
866
|
+
expect(metadata.injectedFiles).not.toContain("TASK.md")
|
|
867
|
+
|
|
868
|
+
// opencode.json should be in injectedFiles
|
|
869
|
+
expect(metadata.injectedFiles).toContain("opencode.json")
|
|
870
|
+
})
|
|
871
|
+
})
|
|
872
|
+
})
|