@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,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
|
+
})
|