@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,712 @@
|
|
|
1
|
+
import { resolve, join } from "path"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { GitService } from "../services/GitService"
|
|
5
|
+
import { ConfigService } from "../services/ConfigService"
|
|
6
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
7
|
+
import { PromptService } from "../services/PromptService"
|
|
8
|
+
import { TemplateService } from "../services/TemplateService"
|
|
9
|
+
import { OpencodeService } from "../services/OpencodeService"
|
|
10
|
+
import { ClaudeService } from "../services/ClaudeService"
|
|
11
|
+
import { initializeManagedFiles, writeAgencyMetadata } from "../types"
|
|
12
|
+
import { RepositoryNotInitializedError } from "../errors"
|
|
13
|
+
import highlight, { done, info, plural } from "../utils/colors"
|
|
14
|
+
import { createLoggers, ensureGitRepo, getTemplateName } from "../utils/effect"
|
|
15
|
+
import {
|
|
16
|
+
makeEmitBranchName,
|
|
17
|
+
extractCleanBranch,
|
|
18
|
+
makeSourceBranchName,
|
|
19
|
+
} from "../utils/pr-branch"
|
|
20
|
+
|
|
21
|
+
interface TaskOptions extends BaseCommandOptions {
|
|
22
|
+
path?: string
|
|
23
|
+
task?: string
|
|
24
|
+
branch?: string
|
|
25
|
+
from?: string
|
|
26
|
+
fromCurrent?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TaskEditOptions extends BaseCommandOptions {}
|
|
30
|
+
|
|
31
|
+
export const task = (options: TaskOptions = {}) =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const { silent = false, verbose = false } = options
|
|
34
|
+
const { log, verboseLog } = createLoggers(options)
|
|
35
|
+
|
|
36
|
+
const git = yield* GitService
|
|
37
|
+
const configService = yield* ConfigService
|
|
38
|
+
const fs = yield* FileSystemService
|
|
39
|
+
const promptService = yield* PromptService
|
|
40
|
+
const templateService = yield* TemplateService
|
|
41
|
+
const opencodeService = yield* OpencodeService
|
|
42
|
+
const claudeService = yield* ClaudeService
|
|
43
|
+
|
|
44
|
+
// Determine target path
|
|
45
|
+
let targetPath: string
|
|
46
|
+
|
|
47
|
+
if (options.path) {
|
|
48
|
+
// If path is provided, validate it's a git repository root
|
|
49
|
+
targetPath = resolve(options.path)
|
|
50
|
+
|
|
51
|
+
const isRoot = yield* git.isGitRoot(targetPath)
|
|
52
|
+
if (!isRoot) {
|
|
53
|
+
return yield* Effect.fail(
|
|
54
|
+
new Error(
|
|
55
|
+
"The specified path is not the root of a git repository. Please provide a path to the top-level directory of a git checkout.",
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
// If no path provided, use git root of current directory
|
|
61
|
+
const isRepo = yield* git.isInsideGitRepo(process.cwd())
|
|
62
|
+
if (!isRepo) {
|
|
63
|
+
return yield* Effect.fail(
|
|
64
|
+
new Error(
|
|
65
|
+
"Not in a git repository. Please run this command inside a git repo.",
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
targetPath = yield* git.getGitRoot(process.cwd())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const createdFiles: string[] = []
|
|
74
|
+
const injectedFiles: string[] = []
|
|
75
|
+
|
|
76
|
+
// Check if initialized (has template in git config)
|
|
77
|
+
const templateName = yield* getTemplateName(targetPath)
|
|
78
|
+
|
|
79
|
+
if (!templateName) {
|
|
80
|
+
return yield* Effect.fail(new RepositoryNotInitializedError())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
verboseLog(`Using template: ${templateName}`)
|
|
84
|
+
|
|
85
|
+
// Define path to TASK.md for later checks
|
|
86
|
+
const taskMdPath = resolve(targetPath, "TASK.md")
|
|
87
|
+
|
|
88
|
+
// Check if we're on a feature branch
|
|
89
|
+
const currentBranch = yield* git.getCurrentBranch(targetPath)
|
|
90
|
+
verboseLog(`Current branch: ${highlight.branch(currentBranch)}`)
|
|
91
|
+
const isFeature = yield* git.isFeatureBranch(currentBranch, targetPath)
|
|
92
|
+
verboseLog(`Is feature branch: ${isFeature}`)
|
|
93
|
+
|
|
94
|
+
// Determine base branch to branch from
|
|
95
|
+
let baseBranchToBranchFrom: string | undefined
|
|
96
|
+
|
|
97
|
+
// Handle --from and --from-current flags
|
|
98
|
+
if (options.from && options.fromCurrent) {
|
|
99
|
+
return yield* Effect.fail(
|
|
100
|
+
new Error("Cannot use both --from and --from-current flags together."),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options.fromCurrent) {
|
|
105
|
+
// Branch from current branch
|
|
106
|
+
baseBranchToBranchFrom = currentBranch
|
|
107
|
+
verboseLog(
|
|
108
|
+
`Using current branch as base: ${highlight.branch(currentBranch)}`,
|
|
109
|
+
)
|
|
110
|
+
} else if (options.from) {
|
|
111
|
+
// Branch from specified branch
|
|
112
|
+
baseBranchToBranchFrom = options.from
|
|
113
|
+
verboseLog(
|
|
114
|
+
`Using specified branch as base: ${highlight.branch(options.from)}`,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// Verify the specified branch exists
|
|
118
|
+
const exists = yield* git.branchExists(targetPath, baseBranchToBranchFrom)
|
|
119
|
+
if (!exists) {
|
|
120
|
+
return yield* Effect.fail(
|
|
121
|
+
new Error(
|
|
122
|
+
`Branch ${highlight.branch(baseBranchToBranchFrom)} does not exist.`,
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
// Default: determine main upstream branch
|
|
128
|
+
baseBranchToBranchFrom =
|
|
129
|
+
(yield* git.getMainBranchConfig(targetPath)) ||
|
|
130
|
+
(yield* git.findMainBranch(targetPath)) ||
|
|
131
|
+
undefined
|
|
132
|
+
|
|
133
|
+
// If still no base branch, try to auto-detect from remote
|
|
134
|
+
if (!baseBranchToBranchFrom) {
|
|
135
|
+
const remote = yield* git
|
|
136
|
+
.resolveRemote(targetPath)
|
|
137
|
+
.pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
138
|
+
|
|
139
|
+
const commonBases: string[] = []
|
|
140
|
+
if (remote) {
|
|
141
|
+
commonBases.push(`${remote}/main`, `${remote}/master`)
|
|
142
|
+
}
|
|
143
|
+
commonBases.push("main", "master")
|
|
144
|
+
|
|
145
|
+
for (const base of commonBases) {
|
|
146
|
+
const exists = yield* git.branchExists(targetPath, base)
|
|
147
|
+
if (exists) {
|
|
148
|
+
baseBranchToBranchFrom = base
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (baseBranchToBranchFrom) {
|
|
155
|
+
verboseLog(
|
|
156
|
+
`Auto-detected base branch: ${highlight.branch(baseBranchToBranchFrom)}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Check if the base branch is an agency source branch
|
|
162
|
+
// If so, we need to emit it first and use the emit branch instead
|
|
163
|
+
if (baseBranchToBranchFrom) {
|
|
164
|
+
const config = yield* configService.loadConfig()
|
|
165
|
+
const cleanFromBase = extractCleanBranch(
|
|
166
|
+
baseBranchToBranchFrom,
|
|
167
|
+
config.sourceBranchPattern,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if (cleanFromBase) {
|
|
171
|
+
// This is an agency source branch - we need to use its emit branch
|
|
172
|
+
verboseLog(
|
|
173
|
+
`Base branch ${highlight.branch(baseBranchToBranchFrom)} is an agency source branch`,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const emitBranchName = makeEmitBranchName(
|
|
177
|
+
cleanFromBase,
|
|
178
|
+
config.emitBranch,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// Check if emit branch exists
|
|
182
|
+
const emitExists = yield* git.branchExists(targetPath, emitBranchName)
|
|
183
|
+
|
|
184
|
+
if (!emitExists) {
|
|
185
|
+
return yield* Effect.fail(
|
|
186
|
+
new Error(
|
|
187
|
+
`Base branch ${highlight.branch(baseBranchToBranchFrom)} is an agency source branch, ` +
|
|
188
|
+
`but its emit branch ${highlight.branch(emitBranchName)} does not exist.\n` +
|
|
189
|
+
`Please run 'agency emit' on ${highlight.branch(baseBranchToBranchFrom)} first, ` +
|
|
190
|
+
`or choose a different base branch.`,
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Use the emit branch as the base
|
|
196
|
+
baseBranchToBranchFrom = emitBranchName
|
|
197
|
+
verboseLog(
|
|
198
|
+
`Using emit branch as base: ${highlight.branch(baseBranchToBranchFrom)}`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// If on main branch without a branch name, prompt for it (unless in silent mode)
|
|
204
|
+
let branchName = options.branch
|
|
205
|
+
if (!isFeature && !branchName) {
|
|
206
|
+
if (silent) {
|
|
207
|
+
return yield* Effect.fail(
|
|
208
|
+
new Error(
|
|
209
|
+
`You're currently on ${highlight.branch(currentBranch)}, which appears to be your main branch.\n` +
|
|
210
|
+
`To initialize on a feature branch, either:\n` +
|
|
211
|
+
` 1. Switch to an existing feature branch first, then run 'agency task'\n` +
|
|
212
|
+
` 2. Provide a new branch name: 'agency task <branch-name>'`,
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
branchName = yield* promptService.prompt("Branch name: ")
|
|
217
|
+
if (!branchName) {
|
|
218
|
+
return yield* Effect.fail(
|
|
219
|
+
new Error("Branch name is required when on main branch."),
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
verboseLog(`Branch name from prompt: ${branchName}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// If we have a branch name, apply source pattern and check if branch exists
|
|
226
|
+
let sourceBranchName: string | undefined
|
|
227
|
+
if (branchName) {
|
|
228
|
+
const config = yield* configService.loadConfig()
|
|
229
|
+
sourceBranchName = makeSourceBranchName(
|
|
230
|
+
branchName,
|
|
231
|
+
config.sourceBranchPattern,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
const exists = yield* git.branchExists(targetPath, sourceBranchName)
|
|
235
|
+
if (exists) {
|
|
236
|
+
return yield* Effect.fail(
|
|
237
|
+
new Error(
|
|
238
|
+
`Branch ${highlight.branch(sourceBranchName)} already exists.\n` +
|
|
239
|
+
`Either switch to it first or choose a different branch name.`,
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
verboseLog(
|
|
244
|
+
`Branch ${sourceBranchName} does not exist (from clean name: ${branchName}), will create it`,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// If we're going to create a branch, check if TASK.md will be created and prompt for description first
|
|
249
|
+
let taskDescription: string | undefined
|
|
250
|
+
if (branchName) {
|
|
251
|
+
const taskMdExists = yield* fs.exists(taskMdPath)
|
|
252
|
+
if (!taskMdExists) {
|
|
253
|
+
if (options.task) {
|
|
254
|
+
taskDescription = options.task
|
|
255
|
+
verboseLog(`Using task from option: ${taskDescription}`)
|
|
256
|
+
} else if (!silent) {
|
|
257
|
+
taskDescription = yield* promptService.prompt("Task description: ")
|
|
258
|
+
if (!taskDescription) {
|
|
259
|
+
log(
|
|
260
|
+
info(
|
|
261
|
+
"Skipping task description (TASK.md will use default placeholder)",
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
taskDescription = undefined
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (sourceBranchName) {
|
|
271
|
+
yield* createFeatureBranchEffect(
|
|
272
|
+
targetPath,
|
|
273
|
+
sourceBranchName,
|
|
274
|
+
baseBranchToBranchFrom,
|
|
275
|
+
silent,
|
|
276
|
+
verbose,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Get managed files for later use
|
|
281
|
+
const managedFiles = yield* Effect.tryPromise({
|
|
282
|
+
try: () => initializeManagedFiles(),
|
|
283
|
+
catch: (error) =>
|
|
284
|
+
new Error(`Failed to initialize managed files: ${error}`),
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// Get template directory (it may or may not exist yet)
|
|
288
|
+
const templateDir = yield* templateService.getTemplateDir(templateName)
|
|
289
|
+
|
|
290
|
+
// Prompt for task if TASK.md will be created (only if not already prompted earlier)
|
|
291
|
+
if (taskDescription === undefined) {
|
|
292
|
+
const taskMdExists = yield* fs.exists(taskMdPath)
|
|
293
|
+
if (!taskMdExists) {
|
|
294
|
+
if (options.task) {
|
|
295
|
+
taskDescription = options.task
|
|
296
|
+
verboseLog(`Using task from option: ${taskDescription}`)
|
|
297
|
+
} else if (!silent) {
|
|
298
|
+
taskDescription = yield* promptService.prompt("Task description: ")
|
|
299
|
+
if (!taskDescription) {
|
|
300
|
+
log(
|
|
301
|
+
info(
|
|
302
|
+
"Skipping task description (TASK.md will use default placeholder)",
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
taskDescription = undefined
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Build list of files to create, combining managed files with any additional template files
|
|
312
|
+
const filesToCreate = new Map<string, string>() // fileName -> content source
|
|
313
|
+
|
|
314
|
+
// Start with all managed files (these should always be created)
|
|
315
|
+
for (const managedFile of managedFiles) {
|
|
316
|
+
filesToCreate.set(managedFile.name, "default")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Discover all files from the template directory
|
|
320
|
+
const templateFiles = yield* discoverTemplateFiles(templateDir, verboseLog)
|
|
321
|
+
for (const relativePath of templateFiles) {
|
|
322
|
+
filesToCreate.set(relativePath, "template")
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
verboseLog(
|
|
326
|
+
`Discovered ${templateFiles.length} files in template: ${templateFiles.join(", ")}`,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
// Check if opencode.json or opencode.jsonc already exists before processing files
|
|
330
|
+
const existingOpencodeInfo = yield* opencodeService
|
|
331
|
+
.detectOpencodeFile(targetPath)
|
|
332
|
+
.pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
333
|
+
|
|
334
|
+
// Process each file to create
|
|
335
|
+
for (const [fileName, source] of filesToCreate) {
|
|
336
|
+
const targetFilePath = resolve(targetPath, fileName)
|
|
337
|
+
|
|
338
|
+
// Special handling for opencode.json if opencode.json/jsonc already exists
|
|
339
|
+
if (fileName === "opencode.json" && existingOpencodeInfo) {
|
|
340
|
+
// Merge with existing file instead of creating new one
|
|
341
|
+
verboseLog(
|
|
342
|
+
`Found existing ${existingOpencodeInfo.relativePath}, will merge instructions`,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
// Get the instructions we want to add from our default content
|
|
346
|
+
const managedFile = managedFiles.find((f) => f.name === "opencode.json")
|
|
347
|
+
const defaultContent = managedFile?.defaultContent ?? "{}"
|
|
348
|
+
const defaultConfig = JSON.parse(defaultContent) as {
|
|
349
|
+
instructions?: string[]
|
|
350
|
+
}
|
|
351
|
+
const instructionsToAdd = defaultConfig.instructions || []
|
|
352
|
+
|
|
353
|
+
// Merge the instructions
|
|
354
|
+
const mergedFilePath = yield* opencodeService
|
|
355
|
+
.mergeOpencodeFile(targetPath, instructionsToAdd)
|
|
356
|
+
.pipe(
|
|
357
|
+
Effect.catchAll((error) => {
|
|
358
|
+
verboseLog(
|
|
359
|
+
`Failed to merge ${existingOpencodeInfo.relativePath}: ${error.message}`,
|
|
360
|
+
)
|
|
361
|
+
return Effect.succeed(existingOpencodeInfo.relativePath)
|
|
362
|
+
}),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
// Track the file that was modified (use the actual relative path)
|
|
366
|
+
createdFiles.push(mergedFilePath)
|
|
367
|
+
injectedFiles.push(mergedFilePath)
|
|
368
|
+
|
|
369
|
+
log(done(`Merged ${highlight.file(mergedFilePath)}`))
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Check if file exists in repo - if so, skip injection
|
|
374
|
+
const exists = yield* fs.exists(targetFilePath)
|
|
375
|
+
if (exists) {
|
|
376
|
+
log(info(`Skipped ${highlight.file(fileName)} (exists in repo)`))
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let content: string
|
|
381
|
+
|
|
382
|
+
// Try to read from template first, fall back to default content
|
|
383
|
+
if (source === "template") {
|
|
384
|
+
const templateFilePath = join(templateDir, fileName)
|
|
385
|
+
content = yield* fs.readFile(templateFilePath)
|
|
386
|
+
} else {
|
|
387
|
+
// Use default content from managed files
|
|
388
|
+
const managedFile = managedFiles.find((f) => f.name === fileName)
|
|
389
|
+
content = managedFile?.defaultContent ?? ""
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Replace {task} placeholder in TASK.md if task description was provided
|
|
393
|
+
if (fileName === "TASK.md" && taskDescription) {
|
|
394
|
+
content = content.replace("{task}", taskDescription)
|
|
395
|
+
verboseLog(`Replaced {task} placeholder with: ${taskDescription}`)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
yield* fs.writeFile(targetFilePath, content)
|
|
399
|
+
createdFiles.push(fileName)
|
|
400
|
+
|
|
401
|
+
// Track backpack files (excluding TASK.md and AGENCY.md which are always filtered)
|
|
402
|
+
if (fileName !== "TASK.md" && fileName !== "AGENCY.md") {
|
|
403
|
+
injectedFiles.push(fileName)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
log(done(`Created ${highlight.file(fileName)}`))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Handle CLAUDE.md injection
|
|
410
|
+
const claudeResult = yield* claudeService.injectAgencySection(targetPath)
|
|
411
|
+
if (claudeResult.created) {
|
|
412
|
+
createdFiles.push("CLAUDE.md")
|
|
413
|
+
log(done(`Created ${highlight.file("CLAUDE.md")}`))
|
|
414
|
+
} else if (claudeResult.modified) {
|
|
415
|
+
createdFiles.push("CLAUDE.md")
|
|
416
|
+
log(done(`Updated ${highlight.file("CLAUDE.md")}`))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Auto-detect base branch for this feature branch
|
|
420
|
+
let baseBranch: string | undefined
|
|
421
|
+
|
|
422
|
+
// Check repository-level default in git config
|
|
423
|
+
baseBranch =
|
|
424
|
+
(yield* git.getDefaultBaseBranchConfig(targetPath)) || undefined
|
|
425
|
+
|
|
426
|
+
// If no repo-level default, try to auto-detect
|
|
427
|
+
if (!baseBranch) {
|
|
428
|
+
// Try main branch config
|
|
429
|
+
baseBranch =
|
|
430
|
+
(yield* git.getMainBranchConfig(targetPath)) ||
|
|
431
|
+
(yield* git.findMainBranch(targetPath)) ||
|
|
432
|
+
undefined
|
|
433
|
+
|
|
434
|
+
// Try common base branches with dynamic remote
|
|
435
|
+
if (!baseBranch) {
|
|
436
|
+
const remote = yield* git
|
|
437
|
+
.resolveRemote(targetPath)
|
|
438
|
+
.pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
439
|
+
|
|
440
|
+
const commonBases: string[] = []
|
|
441
|
+
if (remote) {
|
|
442
|
+
commonBases.push(`${remote}/main`, `${remote}/master`)
|
|
443
|
+
}
|
|
444
|
+
commonBases.push("main", "master")
|
|
445
|
+
|
|
446
|
+
for (const base of commonBases) {
|
|
447
|
+
const exists = yield* git.branchExists(targetPath, base)
|
|
448
|
+
if (exists) {
|
|
449
|
+
baseBranch = base
|
|
450
|
+
break
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (baseBranch) {
|
|
457
|
+
verboseLog(`Auto-detected base branch: ${highlight.branch(baseBranch)}`)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Calculate emitBranch name from current branch
|
|
461
|
+
const finalBranch = yield* git.getCurrentBranch(targetPath)
|
|
462
|
+
const config = yield* configService.loadConfig()
|
|
463
|
+
// Extract clean branch name from source pattern, or use branch as-is for legacy branches
|
|
464
|
+
const cleanBranch =
|
|
465
|
+
extractCleanBranch(finalBranch, config.sourceBranchPattern) || finalBranch
|
|
466
|
+
const emitBranchName = makeEmitBranchName(cleanBranch, config.emitBranch)
|
|
467
|
+
|
|
468
|
+
// Create agency.json metadata file
|
|
469
|
+
const metadata = {
|
|
470
|
+
version: 1 as const,
|
|
471
|
+
injectedFiles,
|
|
472
|
+
baseBranch, // Save the base branch if detected
|
|
473
|
+
emitBranch: emitBranchName, // Save the emit branch name
|
|
474
|
+
template: templateName,
|
|
475
|
+
createdAt: new Date().toISOString(),
|
|
476
|
+
}
|
|
477
|
+
yield* Effect.tryPromise({
|
|
478
|
+
try: () => writeAgencyMetadata(targetPath, metadata as any),
|
|
479
|
+
catch: (error) => new Error(`Failed to write agency metadata: ${error}`),
|
|
480
|
+
})
|
|
481
|
+
createdFiles.push("agency.json")
|
|
482
|
+
log(done(`Created ${highlight.file("agency.json")}`))
|
|
483
|
+
if (baseBranch) {
|
|
484
|
+
verboseLog(`Base branch: ${highlight.branch(baseBranch)}`)
|
|
485
|
+
}
|
|
486
|
+
verboseLog(`Tracked backpack file${plural(injectedFiles.length)}`)
|
|
487
|
+
|
|
488
|
+
// Git add and commit the created files
|
|
489
|
+
if (createdFiles.length > 0) {
|
|
490
|
+
yield* Effect.gen(function* () {
|
|
491
|
+
yield* git.gitAdd(createdFiles, targetPath)
|
|
492
|
+
yield* git.gitCommit("chore: agency task", targetPath, {
|
|
493
|
+
noVerify: true,
|
|
494
|
+
})
|
|
495
|
+
verboseLog(
|
|
496
|
+
`Committed ${createdFiles.length} file${plural(createdFiles.length)}`,
|
|
497
|
+
)
|
|
498
|
+
}).pipe(
|
|
499
|
+
Effect.catchAll((err) => {
|
|
500
|
+
// If commit fails, it might be because there are no changes (e.g., files already staged)
|
|
501
|
+
// We can ignore this error and let the user handle it manually
|
|
502
|
+
verboseLog(`Failed to commit: ${err}`)
|
|
503
|
+
return Effect.void
|
|
504
|
+
}),
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
// Helper: Create feature branch with interactive prompts
|
|
510
|
+
const createFeatureBranchEffect = (
|
|
511
|
+
targetPath: string,
|
|
512
|
+
branchName: string,
|
|
513
|
+
providedBaseBranch: string | undefined,
|
|
514
|
+
silent: boolean,
|
|
515
|
+
verbose: boolean,
|
|
516
|
+
) =>
|
|
517
|
+
Effect.gen(function* () {
|
|
518
|
+
const { log, verboseLog } = createLoggers({ silent, verbose })
|
|
519
|
+
const git = yield* GitService
|
|
520
|
+
const promptService = yield* PromptService
|
|
521
|
+
|
|
522
|
+
// Use provided base branch if available, otherwise get or prompt for one
|
|
523
|
+
let baseBranch: string | undefined = providedBaseBranch
|
|
524
|
+
|
|
525
|
+
if (!baseBranch) {
|
|
526
|
+
baseBranch =
|
|
527
|
+
(yield* git.getMainBranchConfig(targetPath)) ||
|
|
528
|
+
(yield* git.findMainBranch(targetPath)) ||
|
|
529
|
+
undefined
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// If no base branch is configured and not in silent mode, prompt for it
|
|
533
|
+
if (!baseBranch && !silent) {
|
|
534
|
+
const suggestions = yield* git.getSuggestedBaseBranches(targetPath)
|
|
535
|
+
if (suggestions.length > 0) {
|
|
536
|
+
baseBranch = yield* promptService.promptForSelection(
|
|
537
|
+
"Select base branch",
|
|
538
|
+
suggestions,
|
|
539
|
+
)
|
|
540
|
+
verboseLog(`Selected base branch: ${baseBranch}`)
|
|
541
|
+
|
|
542
|
+
// Save the main branch config if it's not already set
|
|
543
|
+
const mainBranchConfig = yield* git.getMainBranchConfig(targetPath)
|
|
544
|
+
if (!mainBranchConfig) {
|
|
545
|
+
yield* git.setMainBranchConfig(baseBranch, targetPath)
|
|
546
|
+
log(done(`Set main branch to ${highlight.branch(baseBranch)}`))
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
return yield* Effect.fail(
|
|
550
|
+
new Error(
|
|
551
|
+
"Could not find any base branches. Please ensure your repository has at least one branch.",
|
|
552
|
+
),
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
} else if (!baseBranch && silent) {
|
|
556
|
+
return yield* Effect.fail(
|
|
557
|
+
new Error(
|
|
558
|
+
"No base branch configured. Run without --silent to configure, or set agency.mainBranch in git config.",
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
yield* git.createBranch(branchName, targetPath, baseBranch)
|
|
564
|
+
log(
|
|
565
|
+
done(
|
|
566
|
+
`Created and switched to branch ${highlight.branch(branchName)}${baseBranch ? ` based on ${highlight.branch(baseBranch)}` : ""}`,
|
|
567
|
+
),
|
|
568
|
+
)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
// Helper: Discover template files
|
|
572
|
+
const discoverTemplateFiles = (templateDir: string, verboseLog: Function) =>
|
|
573
|
+
Effect.gen(function* () {
|
|
574
|
+
const fs = yield* FileSystemService
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
const result = yield* fs.runCommand(["find", templateDir, "-type", "f"], {
|
|
578
|
+
captureOutput: true,
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
if (result.stdout) {
|
|
582
|
+
const foundFiles = result.stdout
|
|
583
|
+
.trim()
|
|
584
|
+
.split("\n")
|
|
585
|
+
.filter((f: string) => f.length > 0)
|
|
586
|
+
const templateFiles: string[] = []
|
|
587
|
+
|
|
588
|
+
for (const file of foundFiles) {
|
|
589
|
+
// Get relative path from template directory
|
|
590
|
+
const relativePath = file.replace(templateDir + "/", "")
|
|
591
|
+
if (relativePath && !relativePath.startsWith(".")) {
|
|
592
|
+
templateFiles.push(relativePath)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return templateFiles
|
|
597
|
+
}
|
|
598
|
+
} catch (err) {
|
|
599
|
+
verboseLog(`Error discovering template files: ${err}`)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return []
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
// Effect-based taskEdit implementation
|
|
606
|
+
const taskEditEffect = (options: TaskEditOptions = {}) =>
|
|
607
|
+
Effect.gen(function* () {
|
|
608
|
+
const { log, verboseLog } = createLoggers(options)
|
|
609
|
+
|
|
610
|
+
const git = yield* GitService
|
|
611
|
+
const fs = yield* FileSystemService
|
|
612
|
+
|
|
613
|
+
const gitRoot = yield* ensureGitRepo()
|
|
614
|
+
|
|
615
|
+
const taskFilePath = resolve(gitRoot, "TASK.md")
|
|
616
|
+
verboseLog(`TASK.md path: ${taskFilePath}`)
|
|
617
|
+
|
|
618
|
+
// Check if TASK.md exists
|
|
619
|
+
const exists = yield* fs.exists(taskFilePath)
|
|
620
|
+
if (!exists) {
|
|
621
|
+
return yield* Effect.fail(
|
|
622
|
+
new Error(
|
|
623
|
+
"TASK.md not found in repository root. Run 'agency task' first to create it.",
|
|
624
|
+
),
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Get editor from environment or use sensible defaults
|
|
629
|
+
const editor =
|
|
630
|
+
process.env.VISUAL ||
|
|
631
|
+
process.env.EDITOR ||
|
|
632
|
+
(process.platform === "darwin" ? "open" : "vim")
|
|
633
|
+
|
|
634
|
+
verboseLog(`Using editor: ${editor}`)
|
|
635
|
+
|
|
636
|
+
const result = yield* fs.runCommand([editor, taskFilePath])
|
|
637
|
+
|
|
638
|
+
if (result.exitCode !== 0) {
|
|
639
|
+
return yield* Effect.fail(
|
|
640
|
+
new Error(`Editor exited with code ${result.exitCode}`),
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
log(done("TASK.md edited"))
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
export const help = `
|
|
648
|
+
Usage: agency task [branch-name] [options]
|
|
649
|
+
|
|
650
|
+
Initialize template files (AGENTS.md, TASK.md, opencode.json) in a git repository.
|
|
651
|
+
|
|
652
|
+
IMPORTANT:
|
|
653
|
+
- You must run 'agency init' first to select a template
|
|
654
|
+
- This command must be run on a feature branch, not the main branch
|
|
655
|
+
|
|
656
|
+
If you're on the main branch, you must either:
|
|
657
|
+
1. Switch to an existing feature branch first, then run 'agency task'
|
|
658
|
+
2. Provide a branch name: 'agency task <branch-name>'
|
|
659
|
+
|
|
660
|
+
Initializes files at the root of the current git repository.
|
|
661
|
+
|
|
662
|
+
Arguments:
|
|
663
|
+
branch-name Create and switch to this branch before initializing
|
|
664
|
+
|
|
665
|
+
Options:
|
|
666
|
+
-b, --branch Branch name to create (alternative to positional arg)
|
|
667
|
+
--from <branch> Branch to branch from instead of main upstream branch
|
|
668
|
+
--from-current Branch from the current branch
|
|
669
|
+
|
|
670
|
+
Base Branch Selection:
|
|
671
|
+
By default, 'agency task' branches from the main upstream branch (e.g., origin/main).
|
|
672
|
+
You can override this behavior with:
|
|
673
|
+
|
|
674
|
+
- --from <branch>: Branch from a specific branch
|
|
675
|
+
- --from-current: Branch from your current branch
|
|
676
|
+
|
|
677
|
+
If the base branch is an agency source branch (e.g., agency/branch-A), the command
|
|
678
|
+
will automatically use its emit branch instead. This allows you to layer work on top
|
|
679
|
+
of other feature branches while maintaining clean branch history.
|
|
680
|
+
|
|
681
|
+
Examples:
|
|
682
|
+
agency task # Branch from main upstream branch
|
|
683
|
+
agency task --from agency/branch-B # Branch from agency/branch-B's emit branch
|
|
684
|
+
agency task --from-current # Branch from current branch's emit branch
|
|
685
|
+
agency task my-feature --from develop # Create 'my-feature' from 'develop'
|
|
686
|
+
|
|
687
|
+
Template Workflow:
|
|
688
|
+
1. Run 'agency init' to select template (saved to .git/config)
|
|
689
|
+
2. Run 'agency task' to create template files on feature branch
|
|
690
|
+
3. Use 'agency template save <file>' to update template with local changes
|
|
691
|
+
4. Template directory only created when you save files to it
|
|
692
|
+
|
|
693
|
+
Branch Creation:
|
|
694
|
+
When creating a new branch without --from or --from-current:
|
|
695
|
+
1. Auto-detects main upstream branch (origin/main, origin/master, etc.)
|
|
696
|
+
2. Falls back to configured main branch in .git/config (agency.mainBranch)
|
|
697
|
+
3. In --silent mode, a base branch must already be configured
|
|
698
|
+
|
|
699
|
+
When using --from with an agency source branch:
|
|
700
|
+
1. Verifies the emit branch exists for the source branch
|
|
701
|
+
2. Uses the emit branch as the actual base to avoid agency files
|
|
702
|
+
3. Fails if emit branch doesn't exist (run 'agency emit' first)
|
|
703
|
+
|
|
704
|
+
Notes:
|
|
705
|
+
- Files are created at the git repository root, not the current directory
|
|
706
|
+
- If files already exist in the repository, they will not be overwritten
|
|
707
|
+
- Template selection is stored in .git/config (not committed)
|
|
708
|
+
- To edit TASK.md after creation, use 'agency edit'
|
|
709
|
+
`
|
|
710
|
+
|
|
711
|
+
export const taskEdit = (options: TaskEditOptions = {}) =>
|
|
712
|
+
taskEditEffect(options)
|