@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,263 @@
|
|
|
1
|
+
import { Effect, Data } from "effect"
|
|
2
|
+
import { parse as parseJsonc, type ParseError } from "jsonc-parser"
|
|
3
|
+
import { FileSystemService } from "./FileSystemService"
|
|
4
|
+
|
|
5
|
+
// Error types for Opencode operations
|
|
6
|
+
class OpencodeError extends Data.TaggedError("OpencodeError")<{
|
|
7
|
+
message: string
|
|
8
|
+
cause?: unknown
|
|
9
|
+
}> {}
|
|
10
|
+
|
|
11
|
+
interface OpencodeConfig {
|
|
12
|
+
instructions?: string[]
|
|
13
|
+
[key: string]: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface OpencodeFileInfo {
|
|
17
|
+
extension: "json" | "jsonc"
|
|
18
|
+
relativePath: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Service for handling opencode.json/opencode.jsonc files
|
|
23
|
+
* with merging capabilities to preserve existing configuration.
|
|
24
|
+
*/
|
|
25
|
+
export class OpencodeService extends Effect.Service<OpencodeService>()(
|
|
26
|
+
"OpencodeService",
|
|
27
|
+
{
|
|
28
|
+
sync: () => ({
|
|
29
|
+
/**
|
|
30
|
+
* Detect which opencode config file exists (json or jsonc).
|
|
31
|
+
* Checks in order: .opencode/opencode.jsonc, .opencode/opencode.json,
|
|
32
|
+
* opencode.jsonc, opencode.json
|
|
33
|
+
* Returns the file info if found, null otherwise.
|
|
34
|
+
*/
|
|
35
|
+
detectOpencodeFile: (gitRoot: string) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const fs = yield* FileSystemService
|
|
38
|
+
|
|
39
|
+
// Check .opencode directory first
|
|
40
|
+
const dotOpencodeJsoncPath = `${gitRoot}/.opencode/opencode.jsonc`
|
|
41
|
+
const dotOpencodeJsoncExists = yield* fs.exists(dotOpencodeJsoncPath)
|
|
42
|
+
if (dotOpencodeJsoncExists) {
|
|
43
|
+
return {
|
|
44
|
+
extension: "jsonc" as const,
|
|
45
|
+
relativePath: ".opencode/opencode.jsonc",
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const dotOpencodeJsonPath = `${gitRoot}/.opencode/opencode.json`
|
|
50
|
+
const dotOpencodeJsonExists = yield* fs.exists(dotOpencodeJsonPath)
|
|
51
|
+
if (dotOpencodeJsonExists) {
|
|
52
|
+
return {
|
|
53
|
+
extension: "json" as const,
|
|
54
|
+
relativePath: ".opencode/opencode.json",
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check for opencode.jsonc in root (takes precedence over opencode.json)
|
|
59
|
+
const jsoncPath = `${gitRoot}/opencode.jsonc`
|
|
60
|
+
const jsoncExists = yield* fs.exists(jsoncPath)
|
|
61
|
+
if (jsoncExists) {
|
|
62
|
+
return {
|
|
63
|
+
extension: "jsonc" as const,
|
|
64
|
+
relativePath: "opencode.jsonc",
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check for opencode.json in root
|
|
69
|
+
const jsonPath = `${gitRoot}/opencode.json`
|
|
70
|
+
const jsonExists = yield* fs.exists(jsonPath)
|
|
71
|
+
if (jsonExists) {
|
|
72
|
+
return { extension: "json" as const, relativePath: "opencode.json" }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null
|
|
76
|
+
}),
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse JSON or JSONC content using jsonc-parser for robust comment handling.
|
|
80
|
+
*/
|
|
81
|
+
parseConfig: (content: string, extension: "json" | "jsonc") =>
|
|
82
|
+
Effect.try({
|
|
83
|
+
try: () => {
|
|
84
|
+
if (extension === "jsonc") {
|
|
85
|
+
// Use jsonc-parser for robust JSONC parsing with comments and trailing commas
|
|
86
|
+
const errors: ParseError[] = []
|
|
87
|
+
const result = parseJsonc(content, errors, {
|
|
88
|
+
allowTrailingComma: true,
|
|
89
|
+
}) as OpencodeConfig
|
|
90
|
+
|
|
91
|
+
// Check if there were any parse errors
|
|
92
|
+
if (errors.length > 0) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`JSON Parse error: ${errors.map((e) => `Error code ${e.error} at offset ${e.offset}`).join(", ")}`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
return JSON.parse(content) as OpencodeConfig
|
|
101
|
+
},
|
|
102
|
+
catch: (error) =>
|
|
103
|
+
new OpencodeError({
|
|
104
|
+
message: `Failed to parse opencode.${extension}`,
|
|
105
|
+
cause: error,
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Merge our instructions into an existing opencode config.
|
|
111
|
+
* Preserves all existing properties and adds our instructions to the array.
|
|
112
|
+
*/
|
|
113
|
+
mergeConfig: (
|
|
114
|
+
existingConfig: OpencodeConfig,
|
|
115
|
+
instructionsToAdd: string[],
|
|
116
|
+
) =>
|
|
117
|
+
Effect.sync(() => {
|
|
118
|
+
const merged = { ...existingConfig }
|
|
119
|
+
|
|
120
|
+
// Get existing instructions or initialize empty array
|
|
121
|
+
const existingInstructions = Array.isArray(merged.instructions)
|
|
122
|
+
? merged.instructions
|
|
123
|
+
: []
|
|
124
|
+
|
|
125
|
+
// Add our instructions, avoiding duplicates
|
|
126
|
+
const newInstructions = instructionsToAdd.filter(
|
|
127
|
+
(instruction) => !existingInstructions.includes(instruction),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
// Update instructions array
|
|
131
|
+
merged.instructions = [...existingInstructions, ...newInstructions]
|
|
132
|
+
|
|
133
|
+
return merged
|
|
134
|
+
}),
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Format the config back to string, preserving formatting.
|
|
138
|
+
* For JSONC, we use JSON formatting (comments are not preserved).
|
|
139
|
+
*/
|
|
140
|
+
formatConfig: (config: OpencodeConfig) =>
|
|
141
|
+
Effect.sync(() => JSON.stringify(config, null, "\t") + "\n"),
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Read, merge, and write the opencode config file.
|
|
145
|
+
* Returns the relative path of the file that was written.
|
|
146
|
+
*/
|
|
147
|
+
mergeOpencodeFile: (gitRoot: string, instructionsToAdd: string[]) =>
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
const fs = yield* FileSystemService
|
|
150
|
+
|
|
151
|
+
// Detect which file exists
|
|
152
|
+
const fileInfo = yield* Effect.gen(function* () {
|
|
153
|
+
const detected = yield* Effect.gen(function* () {
|
|
154
|
+
// Check .opencode directory first
|
|
155
|
+
const dotOpencodeJsoncPath = `${gitRoot}/.opencode/opencode.jsonc`
|
|
156
|
+
const dotOpencodeJsoncExists =
|
|
157
|
+
yield* fs.exists(dotOpencodeJsoncPath)
|
|
158
|
+
if (dotOpencodeJsoncExists) {
|
|
159
|
+
return {
|
|
160
|
+
extension: "jsonc" as const,
|
|
161
|
+
relativePath: ".opencode/opencode.jsonc",
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const dotOpencodeJsonPath = `${gitRoot}/.opencode/opencode.json`
|
|
166
|
+
const dotOpencodeJsonExists =
|
|
167
|
+
yield* fs.exists(dotOpencodeJsonPath)
|
|
168
|
+
if (dotOpencodeJsonExists) {
|
|
169
|
+
return {
|
|
170
|
+
extension: "json" as const,
|
|
171
|
+
relativePath: ".opencode/opencode.json",
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check root directory
|
|
176
|
+
const jsoncPath = `${gitRoot}/opencode.jsonc`
|
|
177
|
+
const jsoncExists = yield* fs.exists(jsoncPath)
|
|
178
|
+
if (jsoncExists) {
|
|
179
|
+
return {
|
|
180
|
+
extension: "jsonc" as const,
|
|
181
|
+
relativePath: "opencode.jsonc",
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const jsonPath = `${gitRoot}/opencode.json`
|
|
186
|
+
const jsonExists = yield* fs.exists(jsonPath)
|
|
187
|
+
if (jsonExists) {
|
|
188
|
+
return {
|
|
189
|
+
extension: "json" as const,
|
|
190
|
+
relativePath: "opencode.json",
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return null
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
if (!detected) {
|
|
198
|
+
return yield* Effect.fail(
|
|
199
|
+
new OpencodeError({
|
|
200
|
+
message: "No opencode.json or opencode.jsonc file found",
|
|
201
|
+
}),
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return detected
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const filepath = `${gitRoot}/${fileInfo.relativePath}`
|
|
209
|
+
|
|
210
|
+
// Read existing config
|
|
211
|
+
const content = yield* fs.readFile(filepath)
|
|
212
|
+
|
|
213
|
+
// Parse it
|
|
214
|
+
const existingConfig = yield* Effect.gen(function* () {
|
|
215
|
+
if (fileInfo.extension === "jsonc") {
|
|
216
|
+
const errors: ParseError[] = []
|
|
217
|
+
const result = parseJsonc(content, errors, {
|
|
218
|
+
allowTrailingComma: true,
|
|
219
|
+
}) as OpencodeConfig
|
|
220
|
+
|
|
221
|
+
// Check if there were any parse errors
|
|
222
|
+
if (errors.length > 0) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`JSON Parse error: ${errors.map((e) => `Error code ${e.error} at offset ${e.offset}`).join(", ")}`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return result
|
|
229
|
+
}
|
|
230
|
+
return JSON.parse(content) as OpencodeConfig
|
|
231
|
+
}).pipe(
|
|
232
|
+
Effect.catchAll((error) =>
|
|
233
|
+
Effect.fail(
|
|
234
|
+
new OpencodeError({
|
|
235
|
+
message: `Failed to parse ${fileInfo.relativePath}`,
|
|
236
|
+
cause: error,
|
|
237
|
+
}),
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
// Merge with our instructions
|
|
243
|
+
const merged = yield* Effect.sync(() => {
|
|
244
|
+
const result = { ...existingConfig }
|
|
245
|
+
const existingInstructions = Array.isArray(result.instructions)
|
|
246
|
+
? result.instructions
|
|
247
|
+
: []
|
|
248
|
+
const newInstructions = instructionsToAdd.filter(
|
|
249
|
+
(instruction) => !existingInstructions.includes(instruction),
|
|
250
|
+
)
|
|
251
|
+
result.instructions = [...existingInstructions, ...newInstructions]
|
|
252
|
+
return result
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// Format and write back
|
|
256
|
+
const formattedContent = JSON.stringify(merged, null, "\t") + "\n"
|
|
257
|
+
yield* fs.writeFile(filepath, formattedContent)
|
|
258
|
+
|
|
259
|
+
return fileInfo.relativePath
|
|
260
|
+
}),
|
|
261
|
+
}),
|
|
262
|
+
},
|
|
263
|
+
) {}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Effect, Data } from "effect"
|
|
2
|
+
import { createInterface } from "node:readline"
|
|
3
|
+
import highlight from "../utils/colors"
|
|
4
|
+
|
|
5
|
+
// Error types for Prompt operations
|
|
6
|
+
class PromptError extends Data.TaggedError("PromptError")<{
|
|
7
|
+
message: string
|
|
8
|
+
cause?: unknown
|
|
9
|
+
}> {}
|
|
10
|
+
|
|
11
|
+
// Prompt Service using Effect.Service pattern
|
|
12
|
+
export class PromptService extends Effect.Service<PromptService>()(
|
|
13
|
+
"PromptService",
|
|
14
|
+
{
|
|
15
|
+
sync: () => ({
|
|
16
|
+
prompt: (question: string, defaultValue?: string) =>
|
|
17
|
+
Effect.tryPromise({
|
|
18
|
+
try: () =>
|
|
19
|
+
new Promise<string>((resolve) => {
|
|
20
|
+
const rl = createInterface({
|
|
21
|
+
input: process.stdin,
|
|
22
|
+
output: process.stdout,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
rl.question(question, (answer) => {
|
|
26
|
+
rl.close()
|
|
27
|
+
resolve(answer.trim())
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// If a default value is provided, pre-fill it in the prompt
|
|
31
|
+
// This allows the user to backspace and delete it
|
|
32
|
+
if (defaultValue) {
|
|
33
|
+
rl.write(defaultValue)
|
|
34
|
+
}
|
|
35
|
+
}),
|
|
36
|
+
catch: (error) =>
|
|
37
|
+
new PromptError({
|
|
38
|
+
message: "Failed to prompt user for input",
|
|
39
|
+
cause: error,
|
|
40
|
+
}),
|
|
41
|
+
}),
|
|
42
|
+
|
|
43
|
+
promptForSelection: (message: string, options: readonly string[]) =>
|
|
44
|
+
Effect.tryPromise({
|
|
45
|
+
try: () =>
|
|
46
|
+
new Promise<string>((resolve, reject) => {
|
|
47
|
+
console.log(message)
|
|
48
|
+
options.forEach((option, index) => {
|
|
49
|
+
console.log(` ${index + 1}. ${option}`)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const rl = createInterface({
|
|
53
|
+
input: process.stdin,
|
|
54
|
+
output: process.stdout,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
rl.question(
|
|
58
|
+
`\nSelect option (1-${options.length}) or enter custom value: `,
|
|
59
|
+
(answer) => {
|
|
60
|
+
rl.close()
|
|
61
|
+
|
|
62
|
+
// Check if it's a number selection
|
|
63
|
+
const selection = parseInt(answer, 10)
|
|
64
|
+
if (
|
|
65
|
+
!isNaN(selection) &&
|
|
66
|
+
selection >= 1 &&
|
|
67
|
+
selection <= options.length
|
|
68
|
+
) {
|
|
69
|
+
const selected = options[selection - 1]
|
|
70
|
+
if (selected === undefined) {
|
|
71
|
+
reject(new Error("Invalid selection"))
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
resolve(selected)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Otherwise treat as custom value
|
|
79
|
+
resolve(answer.trim())
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
}),
|
|
83
|
+
catch: (error) =>
|
|
84
|
+
new PromptError({
|
|
85
|
+
message: "Failed to prompt user for selection",
|
|
86
|
+
cause: error,
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
|
|
90
|
+
sanitizeTemplateName: (name: string) =>
|
|
91
|
+
Effect.sync(() => {
|
|
92
|
+
// Replace problematic characters with hyphens
|
|
93
|
+
return name
|
|
94
|
+
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
|
95
|
+
.replace(/-+/g, "-")
|
|
96
|
+
.replace(/^-|-$/g, "")
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
}),
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Prompt user to select a template from a list.
|
|
102
|
+
* Returns the selected template name or a new name entered by user.
|
|
103
|
+
*/
|
|
104
|
+
promptForTemplate: (
|
|
105
|
+
templates: readonly string[],
|
|
106
|
+
options?: {
|
|
107
|
+
readonly currentTemplate?: string
|
|
108
|
+
readonly defaultValue?: string
|
|
109
|
+
readonly allowNew?: boolean
|
|
110
|
+
},
|
|
111
|
+
) =>
|
|
112
|
+
Effect.tryPromise({
|
|
113
|
+
try: () =>
|
|
114
|
+
new Promise<string>((resolve, reject) => {
|
|
115
|
+
const {
|
|
116
|
+
currentTemplate,
|
|
117
|
+
defaultValue,
|
|
118
|
+
allowNew = true,
|
|
119
|
+
} = options ?? {}
|
|
120
|
+
|
|
121
|
+
// Display template list
|
|
122
|
+
if (templates.length > 0) {
|
|
123
|
+
console.log("\nAvailable templates:")
|
|
124
|
+
templates.forEach((t, i) => {
|
|
125
|
+
const current = t === currentTemplate ? " (current)" : ""
|
|
126
|
+
console.log(
|
|
127
|
+
` ${highlight.value(i + 1)}. ${highlight.template(t)}${current}`,
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
console.log("")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const rl = createInterface({
|
|
134
|
+
input: process.stdin,
|
|
135
|
+
output: process.stdout,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const promptText =
|
|
139
|
+
templates.length > 0
|
|
140
|
+
? allowNew
|
|
141
|
+
? `Template name (1-${templates.length}) or enter new name: `
|
|
142
|
+
: `Template name (or number): `
|
|
143
|
+
: "Template name: "
|
|
144
|
+
|
|
145
|
+
rl.question(promptText, (answer) => {
|
|
146
|
+
rl.close()
|
|
147
|
+
|
|
148
|
+
const trimmed = answer.trim()
|
|
149
|
+
if (!trimmed) {
|
|
150
|
+
reject(new Error("Template name is required."))
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check if answer is a number (template selection)
|
|
155
|
+
const num = parseInt(trimmed, 10)
|
|
156
|
+
if (!isNaN(num) && num >= 1 && num <= templates.length) {
|
|
157
|
+
const selected = templates[num - 1]
|
|
158
|
+
if (!selected) {
|
|
159
|
+
reject(new Error("Invalid selection"))
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
resolve(selected)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Otherwise treat as template name
|
|
167
|
+
resolve(trimmed)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// Pre-fill default value if provided
|
|
171
|
+
if (defaultValue) {
|
|
172
|
+
rl.write(defaultValue)
|
|
173
|
+
}
|
|
174
|
+
}),
|
|
175
|
+
catch: (error) =>
|
|
176
|
+
new PromptError({
|
|
177
|
+
message: "Failed to prompt user for template selection",
|
|
178
|
+
cause: error,
|
|
179
|
+
}),
|
|
180
|
+
}),
|
|
181
|
+
}),
|
|
182
|
+
},
|
|
183
|
+
) {}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Effect, Data } from "effect"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
getAgencyConfigDir,
|
|
5
|
+
getTemplateDir,
|
|
6
|
+
getTemplatesDir,
|
|
7
|
+
} from "../utils/paths"
|
|
8
|
+
|
|
9
|
+
// Error types for Template operations
|
|
10
|
+
class TemplateError extends Data.TaggedError("TemplateError")<{
|
|
11
|
+
message: string
|
|
12
|
+
cause?: unknown
|
|
13
|
+
}> {}
|
|
14
|
+
|
|
15
|
+
// Template Service using Effect.Service pattern
|
|
16
|
+
export class TemplateService extends Effect.Service<TemplateService>()(
|
|
17
|
+
"TemplateService",
|
|
18
|
+
{
|
|
19
|
+
sync: () => ({
|
|
20
|
+
getConfigDir: () => Effect.sync(() => getAgencyConfigDir()),
|
|
21
|
+
|
|
22
|
+
getTemplateDir: (templateName: string) =>
|
|
23
|
+
Effect.sync(() => getTemplateDir(templateName)),
|
|
24
|
+
|
|
25
|
+
templateExists: (templateName: string) =>
|
|
26
|
+
Effect.tryPromise({
|
|
27
|
+
try: async () => {
|
|
28
|
+
const templateDir = getTemplateDir(templateName)
|
|
29
|
+
const file = Bun.file(join(templateDir, "AGENTS.md"))
|
|
30
|
+
return await file.exists()
|
|
31
|
+
},
|
|
32
|
+
catch: () =>
|
|
33
|
+
new TemplateError({
|
|
34
|
+
message: `Failed to check if template exists: ${templateName}`,
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
37
|
+
|
|
38
|
+
createTemplateDir: (_templateName: string) =>
|
|
39
|
+
Effect.sync(() => {
|
|
40
|
+
// Directory creation is handled by FileSystemService.createDirectory
|
|
41
|
+
}),
|
|
42
|
+
|
|
43
|
+
listTemplates: () =>
|
|
44
|
+
Effect.tryPromise({
|
|
45
|
+
try: async () => {
|
|
46
|
+
const templatesDir = getTemplatesDir()
|
|
47
|
+
|
|
48
|
+
// Check if templates directory exists first
|
|
49
|
+
const templatesFile = Bun.file(templatesDir)
|
|
50
|
+
const exists = await templatesFile.exists()
|
|
51
|
+
|
|
52
|
+
if (!exists) {
|
|
53
|
+
// Return empty array if templates directory doesn't exist
|
|
54
|
+
return [] as readonly string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const entries = await Array.fromAsync(
|
|
58
|
+
new Bun.Glob("*/AGENTS.md").scan({ cwd: templatesDir }),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// Extract template names from paths like "work/AGENTS.md"
|
|
62
|
+
const templates = entries
|
|
63
|
+
.map((entry) => entry.split("/")[0] || "")
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
|
|
66
|
+
return templates as readonly string[]
|
|
67
|
+
},
|
|
68
|
+
catch: () =>
|
|
69
|
+
new TemplateError({
|
|
70
|
+
message: "Failed to list templates",
|
|
71
|
+
}),
|
|
72
|
+
}),
|
|
73
|
+
}),
|
|
74
|
+
},
|
|
75
|
+
) {}
|