@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,91 @@
1
+ import { Effect } from "effect"
2
+ import { FileSystemService } from "./FileSystemService"
3
+
4
+ const AGENCY_SECTION = `
5
+ ## Agency
6
+
7
+ @AGENCY.md
8
+ @TASK.md`
9
+
10
+ /**
11
+ * Service for handling CLAUDE.md files with @-reference injection.
12
+ */
13
+ export class ClaudeService extends Effect.Service<ClaudeService>()(
14
+ "ClaudeService",
15
+ {
16
+ sync: () => ({
17
+ /**
18
+ * Check if CLAUDE.md exists in the git root.
19
+ */
20
+ claudeFileExists: (gitRoot: string) =>
21
+ Effect.gen(function* () {
22
+ const fs = yield* FileSystemService
23
+ const claudePath = `${gitRoot}/CLAUDE.md`
24
+ return yield* fs.exists(claudePath)
25
+ }),
26
+
27
+ /**
28
+ * Check if the CLAUDE.md file already contains the agency section.
29
+ */
30
+ hasAgencySection: (content: string) =>
31
+ Effect.sync(() => {
32
+ // Check if both @AGENCY.md and @TASK.md exist in the content
33
+ return content.includes("@AGENCY.md") && content.includes("@TASK.md")
34
+ }),
35
+
36
+ /**
37
+ * Inject or ensure the agency section exists in CLAUDE.md.
38
+ * If the file doesn't exist, create it with the basic template.
39
+ * If it exists but doesn't have the references, append them.
40
+ * Returns true if the file was modified, false if no changes needed.
41
+ */
42
+ injectAgencySection: (gitRoot: string) =>
43
+ Effect.gen(function* () {
44
+ const fs = yield* FileSystemService
45
+ const claudePath = `${gitRoot}/CLAUDE.md`
46
+
47
+ // Check if file exists
48
+ const exists = yield* fs.exists(claudePath)
49
+
50
+ if (!exists) {
51
+ // Create new CLAUDE.md with basic template
52
+ const content = `# Claude Code Instructions
53
+
54
+ This project uses the agency CLI for managing development tasks and templates.
55
+ ${AGENCY_SECTION}
56
+ `
57
+ yield* fs.writeFile(claudePath, content)
58
+ return { modified: true, created: true }
59
+ }
60
+
61
+ // Read existing content
62
+ const content = yield* fs.readFile(claudePath)
63
+
64
+ // Check if agency section already exists
65
+ const hasSection = yield* Effect.sync(() => {
66
+ return (
67
+ content.includes("@AGENCY.md") && content.includes("@TASK.md")
68
+ )
69
+ })
70
+
71
+ if (hasSection) {
72
+ // Check if they're in the correct order
73
+ const agencyIndex = content.indexOf("@AGENCY.md")
74
+ const taskIndex = content.indexOf("@TASK.md")
75
+
76
+ if (agencyIndex < taskIndex) {
77
+ // Already has the section in correct order
78
+ return { modified: false, created: false }
79
+ }
80
+
81
+ // They exist but in wrong order - we'll re-add them
82
+ }
83
+
84
+ // Append the agency section
85
+ const newContent = content.trimEnd() + "\n" + AGENCY_SECTION + "\n"
86
+ yield* fs.writeFile(claudePath, newContent)
87
+ return { modified: true, created: false }
88
+ }),
89
+ }),
90
+ },
91
+ ) {}
@@ -0,0 +1,115 @@
1
+ import { Effect, Data } from "effect"
2
+ import { Schema } from "@effect/schema"
3
+ import { mkdir } from "node:fs/promises"
4
+ import { AgencyConfig } from "../schemas"
5
+ import { getAgencyConfigDir, getAgencyConfigPath } from "../utils/paths"
6
+
7
+ // Error types for Config operations
8
+ class ConfigError extends Data.TaggedError("ConfigError")<{
9
+ message: string
10
+ cause?: unknown
11
+ }> {}
12
+
13
+ class ConfigWriteError extends Data.TaggedError("ConfigWriteError")<{
14
+ path: string
15
+ cause?: unknown
16
+ }> {}
17
+
18
+ const DEFAULT_CONFIG: AgencyConfig = new AgencyConfig({
19
+ sourceBranchPattern: "agency/%branch%",
20
+ emitBranch: "%branch%",
21
+ })
22
+
23
+ // Config Service using Effect.Service pattern
24
+ export class ConfigService extends Effect.Service<ConfigService>()(
25
+ "ConfigService",
26
+ {
27
+ sync: () => ({
28
+ getConfigDir: () => Effect.sync(() => getAgencyConfigDir()),
29
+
30
+ getConfigPath: () => Effect.sync(() => getAgencyConfigPath()),
31
+
32
+ loadConfig: (configPath?: string) =>
33
+ Effect.gen(function* () {
34
+ const path =
35
+ configPath || (yield* Effect.sync(() => getAgencyConfigPath()))
36
+
37
+ // Check if file exists
38
+ const file = Bun.file(path)
39
+ const exists = yield* Effect.tryPromise({
40
+ try: () => file.exists(),
41
+ catch: () =>
42
+ new ConfigError({
43
+ message: `Failed to check if config exists at ${path}`,
44
+ }),
45
+ })
46
+
47
+ if (!exists) {
48
+ return DEFAULT_CONFIG
49
+ }
50
+
51
+ // Try to read and parse the config
52
+ const data = yield* Effect.tryPromise({
53
+ try: () => file.json(),
54
+ catch: (error) =>
55
+ new ConfigError({
56
+ message: `Failed to read config file at ${path}`,
57
+ cause: error,
58
+ }),
59
+ })
60
+
61
+ // Parse with schema, but fall back to defaults on error
62
+ const parsed = yield* Effect.try({
63
+ try: () => Schema.decodeUnknownSync(AgencyConfig)(data),
64
+ catch: (error) =>
65
+ new ConfigError({
66
+ message: `Invalid config format at ${path}`,
67
+ cause: error,
68
+ }),
69
+ })
70
+
71
+ // If we get here, parsing succeeded
72
+ return parsed || DEFAULT_CONFIG
73
+ }),
74
+
75
+ saveConfig: (config: AgencyConfig, configPath?: string) =>
76
+ Effect.gen(function* () {
77
+ const path =
78
+ configPath || (yield* Effect.sync(() => getAgencyConfigPath()))
79
+
80
+ // Ensure the config directory exists
81
+ const configDir = yield* Effect.sync(() => getAgencyConfigDir())
82
+
83
+ yield* Effect.tryPromise({
84
+ try: () => mkdir(configDir, { recursive: true }),
85
+ catch: (error) =>
86
+ new ConfigWriteError({
87
+ path: configDir,
88
+ cause: error,
89
+ }),
90
+ })
91
+
92
+ // Encode and write the config
93
+ const encoded = yield* Effect.try({
94
+ try: () => Schema.encodeSync(AgencyConfig)(config),
95
+ catch: (error) =>
96
+ new ConfigWriteError({
97
+ path,
98
+ cause: error,
99
+ }),
100
+ })
101
+
102
+ yield* Effect.tryPromise({
103
+ try: () => Bun.write(path, JSON.stringify(encoded, null, 2) + "\n"),
104
+ catch: (error) =>
105
+ new ConfigWriteError({
106
+ path,
107
+ cause: error,
108
+ }),
109
+ })
110
+ }),
111
+
112
+ getDefaultConfig: () => Effect.succeed(DEFAULT_CONFIG),
113
+ }),
114
+ },
115
+ ) {}
@@ -0,0 +1,222 @@
1
+ import { Effect, Data, pipe } from "effect"
2
+ import { mkdir, copyFile as fsCopyFile, unlink } from "node:fs/promises"
3
+ import { spawnProcess } from "../utils/process"
4
+
5
+ // Error types for FileSystem operations
6
+ class FileSystemError extends Data.TaggedError("FileSystemError")<{
7
+ message: string
8
+ cause?: unknown
9
+ }> {}
10
+
11
+ class FileNotFoundError extends Data.TaggedError("FileNotFoundError")<{
12
+ path: string
13
+ }> {}
14
+
15
+ // FileSystem Service using Effect.Service pattern
16
+ export class FileSystemService extends Effect.Service<FileSystemService>()(
17
+ "FileSystemService",
18
+ {
19
+ sync: () => ({
20
+ exists: (path: string) =>
21
+ Effect.tryPromise({
22
+ try: async () => {
23
+ const file = Bun.file(path)
24
+ return await file.exists()
25
+ },
26
+ catch: () =>
27
+ new FileSystemError({
28
+ message: `Failed to check if file exists: ${path}`,
29
+ }),
30
+ }),
31
+
32
+ isDirectory: (path: string) =>
33
+ Effect.tryPromise({
34
+ try: async () => {
35
+ const file = Bun.file(path)
36
+ const exists = await file.exists()
37
+ if (!exists) {
38
+ return false
39
+ }
40
+ // Use stat to check if it's a directory
41
+ const stat = await import("node:fs/promises").then((fs) =>
42
+ fs.stat(path),
43
+ )
44
+ return stat.isDirectory()
45
+ },
46
+ catch: () =>
47
+ new FileSystemError({
48
+ message: `Failed to check if path is directory: ${path}`,
49
+ }),
50
+ }),
51
+
52
+ readFile: (path: string) =>
53
+ Effect.tryPromise({
54
+ try: async () => {
55
+ const file = Bun.file(path)
56
+ const exists = await file.exists()
57
+ if (!exists) {
58
+ throw new Error(`File not found: ${path}`)
59
+ }
60
+ return await file.text()
61
+ },
62
+ catch: () => new FileNotFoundError({ path }),
63
+ }),
64
+
65
+ writeFile: (path: string, content: string) =>
66
+ Effect.tryPromise({
67
+ try: () => Bun.write(path, content),
68
+ catch: (error) =>
69
+ new FileSystemError({
70
+ message: `Failed to write file: ${path}`,
71
+ cause: error,
72
+ }),
73
+ }),
74
+
75
+ readJSON: <T = unknown>(path: string) =>
76
+ Effect.tryPromise({
77
+ try: async (): Promise<T> => {
78
+ const file = Bun.file(path)
79
+ const exists = await file.exists()
80
+ if (!exists) {
81
+ throw new Error(`File not found: ${path}`)
82
+ }
83
+ return await file.json()
84
+ },
85
+ catch: () => new FileNotFoundError({ path }),
86
+ }),
87
+
88
+ writeJSON: <T = unknown>(path: string, data: T) =>
89
+ Effect.tryPromise({
90
+ try: () => Bun.write(path, JSON.stringify(data, null, 2) + "\n"),
91
+ catch: (error) =>
92
+ new FileSystemError({
93
+ message: `Failed to write JSON file: ${path}`,
94
+ cause: error,
95
+ }),
96
+ }),
97
+
98
+ createDirectory: (path: string) =>
99
+ Effect.tryPromise({
100
+ try: () => mkdir(path, { recursive: true }),
101
+ catch: (error) =>
102
+ new FileSystemError({
103
+ message: `Failed to create directory: ${path}`,
104
+ cause: error,
105
+ }),
106
+ }),
107
+
108
+ deleteFile: (path: string) =>
109
+ Effect.tryPromise({
110
+ try: () => unlink(path),
111
+ catch: (error) =>
112
+ new FileSystemError({
113
+ message: `Failed to delete file: ${path}`,
114
+ cause: error,
115
+ }),
116
+ }),
117
+
118
+ copyFile: (from: string, to: string) =>
119
+ Effect.tryPromise({
120
+ try: () => fsCopyFile(from, to),
121
+ catch: (error) =>
122
+ new FileSystemError({
123
+ message: `Failed to copy file from ${from} to ${to}`,
124
+ cause: error,
125
+ }),
126
+ }),
127
+
128
+ deleteDirectory: (path: string) =>
129
+ pipe(
130
+ spawnProcess(["rm", "-rf", path]),
131
+ Effect.flatMap((result) =>
132
+ result.exitCode === 0
133
+ ? Effect.void
134
+ : Effect.fail(
135
+ new FileSystemError({
136
+ message: `Failed to delete directory: ${path}`,
137
+ cause: result.stderr,
138
+ }),
139
+ ),
140
+ ),
141
+ Effect.mapError(
142
+ (error) =>
143
+ new FileSystemError({
144
+ message: `Failed to delete directory: ${path}`,
145
+ cause: error,
146
+ }),
147
+ ),
148
+ ),
149
+
150
+ runCommand: (
151
+ args: readonly string[],
152
+ options?: {
153
+ readonly cwd?: string
154
+ readonly captureOutput?: boolean
155
+ },
156
+ ) =>
157
+ pipe(
158
+ spawnProcess(args, {
159
+ cwd: options?.cwd,
160
+ stdout: options?.captureOutput ? "pipe" : "inherit",
161
+ stderr: "pipe",
162
+ }),
163
+ Effect.mapError(
164
+ (processError) =>
165
+ new FileSystemError({
166
+ message: `Failed to run command: ${args.join(" ")}`,
167
+ cause: processError,
168
+ }),
169
+ ),
170
+ ),
171
+
172
+ /**
173
+ * Recursively collect all files in a directory.
174
+ * Returns paths relative to the directory (or relativeTo if specified).
175
+ */
176
+ collectFiles: (
177
+ dirPath: string,
178
+ options?: {
179
+ readonly relativeTo?: string
180
+ readonly exclude?: readonly string[]
181
+ readonly sort?: boolean
182
+ },
183
+ ) =>
184
+ Effect.tryPromise({
185
+ try: async () => {
186
+ const { relativeTo, exclude = [], sort = false } = options ?? {}
187
+ const basePath = relativeTo ?? dirPath
188
+
189
+ // Build find command with exclusions
190
+ const findArgs = ["find", dirPath, "-type", "f"]
191
+ for (const pattern of exclude) {
192
+ findArgs.push("!", "-name", pattern)
193
+ }
194
+
195
+ const result = Bun.spawnSync(findArgs, {
196
+ stdout: "pipe",
197
+ stderr: "ignore",
198
+ })
199
+
200
+ const output = new TextDecoder().decode(result.stdout)
201
+ if (!output) {
202
+ return []
203
+ }
204
+
205
+ const files = output
206
+ .trim()
207
+ .split("\n")
208
+ .filter((f: string) => f.length > 0)
209
+ .map((file) => file.replace(basePath + "/", ""))
210
+ .filter((f) => f.length > 0)
211
+
212
+ return sort ? files.sort() : files
213
+ },
214
+ catch: (error) =>
215
+ new FileSystemError({
216
+ message: `Failed to collect files from ${dirPath}`,
217
+ cause: error,
218
+ }),
219
+ }),
220
+ }),
221
+ },
222
+ ) {}