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