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