@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,87 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { use } from "./use"
3
+ import {
4
+ createTempDir,
5
+ cleanupTempDir,
6
+ initGitRepo,
7
+ getGitConfig,
8
+ runTestEffect,
9
+ } from "../test-utils"
10
+
11
+ describe("use command", () => {
12
+ let tempDir: string
13
+ let originalCwd: string
14
+ let originalConfigDir: string | undefined
15
+
16
+ beforeEach(async () => {
17
+ tempDir = await createTempDir()
18
+ originalCwd = process.cwd()
19
+ originalConfigDir = process.env.AGENCY_CONFIG_DIR
20
+ // Use a temp config dir
21
+ process.env.AGENCY_CONFIG_DIR = await createTempDir()
22
+ })
23
+
24
+ afterEach(async () => {
25
+ process.chdir(originalCwd)
26
+ if (originalConfigDir !== undefined) {
27
+ process.env.AGENCY_CONFIG_DIR = originalConfigDir
28
+ } else {
29
+ delete process.env.AGENCY_CONFIG_DIR
30
+ }
31
+ if (
32
+ process.env.AGENCY_CONFIG_DIR &&
33
+ process.env.AGENCY_CONFIG_DIR !== originalConfigDir
34
+ ) {
35
+ await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
36
+ }
37
+ await cleanupTempDir(tempDir)
38
+ })
39
+
40
+ test("sets template name in git config", async () => {
41
+ await initGitRepo(tempDir)
42
+ process.chdir(tempDir)
43
+
44
+ await runTestEffect(use({ template: "work", silent: true }))
45
+
46
+ const templateName = await getGitConfig("agency.template", tempDir)
47
+ expect(templateName).toBe("work")
48
+ })
49
+
50
+ test("updates existing template name", async () => {
51
+ await initGitRepo(tempDir)
52
+ process.chdir(tempDir)
53
+
54
+ await runTestEffect(use({ template: "work", silent: true }))
55
+ await runTestEffect(use({ template: "personal", silent: true }))
56
+
57
+ const templateName = await getGitConfig("agency.template", tempDir)
58
+ expect(templateName).toBe("personal")
59
+ })
60
+
61
+ test("throws error when not in git repo", async () => {
62
+ process.chdir(tempDir)
63
+
64
+ await expect(
65
+ runTestEffect(use({ template: "work", silent: true })),
66
+ ).rejects.toThrow("Not in a git repository")
67
+ })
68
+
69
+ test("throws error when no template provided in silent mode", async () => {
70
+ await initGitRepo(tempDir)
71
+ process.chdir(tempDir)
72
+
73
+ await expect(runTestEffect(use({ silent: true }))).rejects.toThrow(
74
+ "Template name required",
75
+ )
76
+ })
77
+
78
+ test("works with --template flag", async () => {
79
+ await initGitRepo(tempDir)
80
+ process.chdir(tempDir)
81
+
82
+ await runTestEffect(use({ template: "client", silent: true }))
83
+
84
+ const templateName = await getGitConfig("agency.template", tempDir)
85
+ expect(templateName).toBe("client")
86
+ })
87
+ })
@@ -0,0 +1,97 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { GitService } from "../services/GitService"
4
+ import { TemplateService } from "../services/TemplateService"
5
+ import { PromptService } from "../services/PromptService"
6
+ import highlight from "../utils/colors"
7
+ import { createLoggers, ensureGitRepo } from "../utils/effect"
8
+
9
+ interface UseOptions extends BaseCommandOptions {
10
+ template?: string
11
+ }
12
+
13
+ export const use = (options: UseOptions = {}) =>
14
+ Effect.gen(function* () {
15
+ const { silent = false } = options
16
+ const { log, verboseLog } = createLoggers(options)
17
+
18
+ const git = yield* GitService
19
+ const templateService = yield* TemplateService
20
+ const promptService = yield* PromptService
21
+
22
+ const gitRoot = yield* ensureGitRepo()
23
+
24
+ let templateName = options.template
25
+
26
+ // If no template name provided, show interactive selection
27
+ if (!templateName) {
28
+ if (silent) {
29
+ return yield* Effect.fail(
30
+ new Error(
31
+ "Template name required. Use --template flag in silent mode.",
32
+ ),
33
+ )
34
+ }
35
+
36
+ const templates = yield* templateService.listTemplates()
37
+
38
+ if (templates.length === 0) {
39
+ log("No templates found in ~/.config/agency/templates/")
40
+ log("Run 'agency task' to create a template.")
41
+ return
42
+ }
43
+
44
+ // Show current template if set
45
+ const currentTemplate = yield* git.getGitConfig(
46
+ "agency.template",
47
+ gitRoot,
48
+ )
49
+ if (currentTemplate) {
50
+ log(`Current template: ${highlight.template(currentTemplate)}`)
51
+ }
52
+
53
+ templateName = yield* promptService.promptForTemplate(templates, {
54
+ currentTemplate: currentTemplate ?? undefined,
55
+ allowNew: false,
56
+ })
57
+ }
58
+
59
+ verboseLog(`Setting template to: ${templateName}`)
60
+
61
+ // Set the template in git config
62
+ yield* git.setGitConfig("agency.template", templateName, gitRoot)
63
+ })
64
+
65
+ // Help text for reference (not exported as it's handled by template command)
66
+ const help = `
67
+ Usage: agency template use [template] [options]
68
+
69
+ Set the template to use for this repository.
70
+
71
+ NOTE: This command is equivalent to 'agency init'. Use 'agency init' for
72
+ initial setup, and 'agency template use' to change templates later.
73
+
74
+ When no template name is provided, shows an interactive list of available
75
+ templates to choose from. The template name is saved to .git/config
76
+ (agency.template) and will be used by subsequent 'agency task' commands.
77
+
78
+ Arguments:
79
+ template Template name to use (optional, prompts if not provided)
80
+
81
+ Options:
82
+ -h, --help Show this help message
83
+ -s, --silent Suppress output messages
84
+ -v, --verbose Show verbose output
85
+ -t, --template Specify template name (same as positional argument)
86
+
87
+ Examples:
88
+ agency template use # Interactive template selection
89
+ agency template use work # Set template to 'work'
90
+ agency template use --template=client # Set template to 'client'
91
+
92
+ Notes:
93
+ - Template must exist in ~/.config/agency/templates/{name}/
94
+ - Template name is saved to .git/config (not committed)
95
+ - Use 'agency task' after changing template to create/update files
96
+ - Template directory is created when you save files to it
97
+ `
@@ -0,0 +1,462 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { work } from "./work"
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ initGitRepo,
8
+ initAgency,
9
+ runTestEffect,
10
+ createCommit,
11
+ } from "../test-utils"
12
+ import { writeFileSync } from "node:fs"
13
+
14
+ /**
15
+ * Helper to mock CLI tool detection and execution for work command tests.
16
+ * Returns restore function to clean up mocks.
17
+ *
18
+ * @param options Configuration for the mock
19
+ * @param options.hasOpencode Whether 'which opencode' should succeed (default: true)
20
+ * @param options.hasClaude Whether 'which claude' should succeed (default: false)
21
+ * @param options.onSpawn Callback when Bun.spawn is called (non-git commands)
22
+ * @returns Restore function to clean up mocks
23
+ */
24
+ function mockCliTools(
25
+ options: {
26
+ hasOpencode?: boolean
27
+ hasClaude?: boolean
28
+ onSpawn?: (args: string[], options: any) => any
29
+ } = {},
30
+ ) {
31
+ const { hasOpencode = true, hasClaude = false, onSpawn } = options
32
+
33
+ const originalSpawn = Bun.spawn
34
+ const originalSpawnSync = Bun.spawnSync
35
+
36
+ // @ts-ignore - mocking for test
37
+ Bun.spawnSync = (args: any, options: any) => {
38
+ // Mock which command to return success/failure based on config
39
+ if (Array.isArray(args) && args[0] === "which") {
40
+ if (args[1] === "opencode") {
41
+ return { exitCode: hasOpencode ? 0 : 1 }
42
+ }
43
+ if (args[1] === "claude") {
44
+ return { exitCode: hasClaude ? 0 : 1 }
45
+ }
46
+ }
47
+ return originalSpawnSync(args, options)
48
+ }
49
+
50
+ // @ts-ignore - mocking for test
51
+ Bun.spawn = (args: any, options: any) => {
52
+ // Allow git commands to pass through
53
+ if (Array.isArray(args) && args[0] === "git") {
54
+ return originalSpawn(args, options)
55
+ }
56
+
57
+ // Call custom handler if provided
58
+ if (onSpawn) {
59
+ return onSpawn(args, options)
60
+ }
61
+
62
+ // Default mock response
63
+ return {
64
+ exited: Promise.resolve(0),
65
+ exitCode: 0,
66
+ stdout: new ReadableStream(),
67
+ stderr: new ReadableStream(),
68
+ }
69
+ }
70
+
71
+ // Return restore function
72
+ return () => {
73
+ // @ts-ignore - restore
74
+ Bun.spawn = originalSpawn
75
+ // @ts-ignore - restore
76
+ Bun.spawnSync = originalSpawnSync
77
+ }
78
+ }
79
+
80
+ describe("work command", () => {
81
+ let tempDir: string
82
+ let originalCwd: string
83
+
84
+ beforeEach(async () => {
85
+ tempDir = await createTempDir()
86
+ originalCwd = process.cwd()
87
+ process.chdir(tempDir)
88
+
89
+ // Initialize git repo
90
+ await initGitRepo(tempDir)
91
+ await createCommit(tempDir, "Initial commit")
92
+ })
93
+
94
+ afterEach(async () => {
95
+ process.chdir(originalCwd)
96
+ await cleanupTempDir(tempDir)
97
+ })
98
+
99
+ describe("error handling", () => {
100
+ test("throws error when TASK.md doesn't exist", async () => {
101
+ expect(
102
+ runTestEffect(work({ silent: true, _noExec: true })),
103
+ ).rejects.toThrow(
104
+ "TASK.md not found. Run 'agency task' first to create it.",
105
+ )
106
+ })
107
+
108
+ test("throws error when not in a git repository", async () => {
109
+ const nonGitDir = await createTempDir()
110
+ process.chdir(nonGitDir)
111
+
112
+ expect(
113
+ runTestEffect(work({ silent: true, _noExec: true })),
114
+ ).rejects.toThrow("Not in a git repository")
115
+
116
+ await cleanupTempDir(nonGitDir)
117
+ })
118
+ })
119
+
120
+ describe("TASK.md validation", () => {
121
+ test("finds TASK.md in git root", async () => {
122
+ // Create TASK.md
123
+ const taskPath = join(tempDir, "TASK.md")
124
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
125
+
126
+ let spawnCalled = false
127
+ let spawnArgs: any[] = []
128
+
129
+ const restore = mockCliTools({
130
+ onSpawn: (args) => {
131
+ spawnCalled = true
132
+ spawnArgs = args
133
+ return {
134
+ exited: Promise.resolve(0),
135
+ }
136
+ },
137
+ })
138
+
139
+ await runTestEffect(work({ silent: true, _noExec: true }))
140
+
141
+ restore()
142
+
143
+ expect(spawnCalled).toBe(true)
144
+ expect(spawnArgs).toEqual(["opencode", "-p", "Start the task"])
145
+ })
146
+ })
147
+
148
+ describe("opencode execution", () => {
149
+ test("passes correct arguments to opencode", async () => {
150
+ // Create TASK.md
151
+ const taskPath = join(tempDir, "TASK.md")
152
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
153
+
154
+ let capturedArgs: string[] = []
155
+ let capturedOptions: any = null
156
+
157
+ const restore = mockCliTools({
158
+ onSpawn: (args, options) => {
159
+ capturedArgs = args
160
+ capturedOptions = options
161
+ return {
162
+ exited: Promise.resolve(0),
163
+ exitCode: 0,
164
+ stdout: new ReadableStream(),
165
+ stderr: new ReadableStream(),
166
+ }
167
+ },
168
+ })
169
+
170
+ await runTestEffect(work({ silent: true, _noExec: true }))
171
+
172
+ restore()
173
+
174
+ expect(capturedArgs).toEqual(["opencode", "-p", "Start the task"])
175
+ // On macOS, temp directories can have /private prefix
176
+ expect(
177
+ capturedOptions.cwd === tempDir ||
178
+ capturedOptions.cwd === `/private${tempDir}`,
179
+ ).toBe(true)
180
+ expect(capturedOptions.stdout).toEqual("inherit")
181
+ expect(capturedOptions.stderr).toEqual("inherit")
182
+ })
183
+
184
+ test("throws error when opencode exits with non-zero code", async () => {
185
+ // Create TASK.md
186
+ const taskPath = join(tempDir, "TASK.md")
187
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
188
+
189
+ const restore = mockCliTools({
190
+ onSpawn: () => ({
191
+ exited: Promise.resolve(1),
192
+ exitCode: 1,
193
+ stdout: new ReadableStream(),
194
+ stderr: new ReadableStream(),
195
+ }),
196
+ })
197
+
198
+ expect(
199
+ runTestEffect(work({ silent: true, _noExec: true })),
200
+ ).rejects.toThrow("opencode exited with code 1")
201
+
202
+ restore()
203
+ })
204
+ })
205
+
206
+ describe("silent mode", () => {
207
+ test("verbose mode logs debug information", async () => {
208
+ // Create TASK.md
209
+ const taskPath = join(tempDir, "TASK.md")
210
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
211
+
212
+ const restore = mockCliTools()
213
+
214
+ // Capture console.log
215
+ const originalLog = console.log
216
+ let logMessages: string[] = []
217
+ console.log = (msg: string) => {
218
+ logMessages.push(msg)
219
+ }
220
+
221
+ await runTestEffect(work({ silent: false, verbose: true, _noExec: true }))
222
+
223
+ console.log = originalLog
224
+ restore()
225
+
226
+ expect(logMessages.some((msg) => msg.includes("Found TASK.md"))).toBe(
227
+ true,
228
+ )
229
+ expect(logMessages.some((msg) => msg.includes("Running opencode"))).toBe(
230
+ true,
231
+ )
232
+ })
233
+
234
+ test("silent flag suppresses verbose output", async () => {
235
+ // Create TASK.md
236
+ const taskPath = join(tempDir, "TASK.md")
237
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
238
+
239
+ const restore = mockCliTools()
240
+
241
+ // Capture console.log
242
+ const originalLog = console.log
243
+ let logCalled = false
244
+ console.log = () => {
245
+ logCalled = true
246
+ }
247
+
248
+ await work({ silent: true, verbose: true, _noExec: true })
249
+
250
+ console.log = originalLog
251
+ restore()
252
+
253
+ expect(logCalled).toBe(false)
254
+ })
255
+ })
256
+
257
+ describe("CLI selection flags", () => {
258
+ test("--opencode flag forces use of OpenCode", async () => {
259
+ // Create TASK.md
260
+ const taskPath = join(tempDir, "TASK.md")
261
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
262
+
263
+ let capturedArgs: string[] = []
264
+
265
+ const restore = mockCliTools({
266
+ onSpawn: (args) => {
267
+ capturedArgs = args
268
+ return {
269
+ exited: Promise.resolve(0),
270
+ exitCode: 0,
271
+ stdout: new ReadableStream(),
272
+ stderr: new ReadableStream(),
273
+ }
274
+ },
275
+ })
276
+
277
+ await runTestEffect(work({ silent: true, _noExec: true, opencode: true }))
278
+
279
+ restore()
280
+
281
+ expect(capturedArgs).toEqual(["opencode", "-p", "Start the task"])
282
+ })
283
+
284
+ test("--claude flag forces use of Claude Code", async () => {
285
+ // Create TASK.md
286
+ const taskPath = join(tempDir, "TASK.md")
287
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
288
+
289
+ let capturedArgs: string[] = []
290
+
291
+ const restore = mockCliTools({
292
+ hasClaude: true,
293
+ onSpawn: (args) => {
294
+ capturedArgs = args
295
+ return {
296
+ exited: Promise.resolve(0),
297
+ exitCode: 0,
298
+ stdout: new ReadableStream(),
299
+ stderr: new ReadableStream(),
300
+ }
301
+ },
302
+ })
303
+
304
+ await runTestEffect(work({ silent: true, _noExec: true, claude: true }))
305
+
306
+ restore()
307
+
308
+ expect(capturedArgs).toEqual(["claude", "Start the task"])
309
+ })
310
+
311
+ test("throws error when both --opencode and --claude flags are used", async () => {
312
+ // Create TASK.md
313
+ const taskPath = join(tempDir, "TASK.md")
314
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
315
+
316
+ expect(
317
+ runTestEffect(
318
+ work({ silent: true, _noExec: true, opencode: true, claude: true }),
319
+ ),
320
+ ).rejects.toThrow(
321
+ "Cannot use both --opencode and --claude flags together. Choose one.",
322
+ )
323
+ })
324
+
325
+ test("throws error when --opencode is used but opencode is not installed", async () => {
326
+ // Create TASK.md
327
+ const taskPath = join(tempDir, "TASK.md")
328
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
329
+
330
+ const restore = mockCliTools({ hasOpencode: false })
331
+
332
+ expect(
333
+ runTestEffect(work({ silent: true, _noExec: true, opencode: true })),
334
+ ).rejects.toThrow(
335
+ "opencode CLI tool not found. Please install OpenCode or remove the --opencode flag.",
336
+ )
337
+
338
+ restore()
339
+ })
340
+
341
+ test("throws error when --claude is used but claude is not installed", async () => {
342
+ // Create TASK.md
343
+ const taskPath = join(tempDir, "TASK.md")
344
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
345
+
346
+ const restore = mockCliTools({ hasClaude: false })
347
+
348
+ expect(
349
+ runTestEffect(work({ silent: true, _noExec: true, claude: true })),
350
+ ).rejects.toThrow(
351
+ "claude CLI tool not found. Please install Claude Code or remove the --claude flag.",
352
+ )
353
+
354
+ restore()
355
+ })
356
+ })
357
+
358
+ describe("extra arguments", () => {
359
+ test("passes extra args to opencode", async () => {
360
+ // Create TASK.md
361
+ const taskPath = join(tempDir, "TASK.md")
362
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
363
+
364
+ let capturedArgs: string[] = []
365
+
366
+ const restore = mockCliTools({
367
+ onSpawn: (args) => {
368
+ capturedArgs = args
369
+ return {
370
+ exited: Promise.resolve(0),
371
+ exitCode: 0,
372
+ stdout: new ReadableStream(),
373
+ stderr: new ReadableStream(),
374
+ }
375
+ },
376
+ })
377
+
378
+ await runTestEffect(
379
+ work({
380
+ silent: true,
381
+ _noExec: true,
382
+ extraArgs: ["--model", "claude-sonnet-4-20250514"],
383
+ }),
384
+ )
385
+
386
+ restore()
387
+
388
+ expect(capturedArgs).toEqual([
389
+ "opencode",
390
+ "-p",
391
+ "Start the task",
392
+ "--model",
393
+ "claude-sonnet-4-20250514",
394
+ ])
395
+ })
396
+
397
+ test("passes extra args to claude", async () => {
398
+ // Create TASK.md
399
+ const taskPath = join(tempDir, "TASK.md")
400
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
401
+
402
+ let capturedArgs: string[] = []
403
+
404
+ const restore = mockCliTools({
405
+ hasClaude: true,
406
+ onSpawn: (args) => {
407
+ capturedArgs = args
408
+ return {
409
+ exited: Promise.resolve(0),
410
+ exitCode: 0,
411
+ stdout: new ReadableStream(),
412
+ stderr: new ReadableStream(),
413
+ }
414
+ },
415
+ })
416
+
417
+ await runTestEffect(
418
+ work({
419
+ silent: true,
420
+ _noExec: true,
421
+ claude: true,
422
+ extraArgs: ["--arbitrary", "switches"],
423
+ }),
424
+ )
425
+
426
+ restore()
427
+
428
+ expect(capturedArgs).toEqual([
429
+ "claude",
430
+ "Start the task",
431
+ "--arbitrary",
432
+ "switches",
433
+ ])
434
+ })
435
+
436
+ test("works without extra args", async () => {
437
+ // Create TASK.md
438
+ const taskPath = join(tempDir, "TASK.md")
439
+ writeFileSync(taskPath, "# Test Task\n\nSome task content")
440
+
441
+ let capturedArgs: string[] = []
442
+
443
+ const restore = mockCliTools({
444
+ onSpawn: (args) => {
445
+ capturedArgs = args
446
+ return {
447
+ exited: Promise.resolve(0),
448
+ exitCode: 0,
449
+ stdout: new ReadableStream(),
450
+ stderr: new ReadableStream(),
451
+ }
452
+ },
453
+ })
454
+
455
+ await runTestEffect(work({ silent: true, _noExec: true }))
456
+
457
+ restore()
458
+
459
+ expect(capturedArgs).toEqual(["opencode", "-p", "Start the task"])
460
+ })
461
+ })
462
+ })