@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,193 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
5
|
+
import { createLoggers, ensureGitRepo } from "../utils/effect"
|
|
6
|
+
import { spawnProcess } from "../utils/process"
|
|
7
|
+
import { execvp } from "../utils/exec"
|
|
8
|
+
|
|
9
|
+
interface WorkOptions extends BaseCommandOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Force use of OpenCode CLI
|
|
12
|
+
*/
|
|
13
|
+
opencode?: boolean
|
|
14
|
+
/**
|
|
15
|
+
* Force use of Claude Code CLI
|
|
16
|
+
*/
|
|
17
|
+
claude?: boolean
|
|
18
|
+
/**
|
|
19
|
+
* Additional arguments to pass to the CLI tool
|
|
20
|
+
*/
|
|
21
|
+
extraArgs?: string[]
|
|
22
|
+
/**
|
|
23
|
+
* Internal option to disable exec for testing.
|
|
24
|
+
* When true, uses spawn instead of exec so tests can complete.
|
|
25
|
+
*/
|
|
26
|
+
_noExec?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const work = (options: WorkOptions = {}) =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const { verboseLog } = createLoggers(options)
|
|
32
|
+
|
|
33
|
+
const fs = yield* FileSystemService
|
|
34
|
+
|
|
35
|
+
const gitRoot = yield* ensureGitRepo()
|
|
36
|
+
|
|
37
|
+
// Check if TASK.md exists
|
|
38
|
+
const taskPath = join(gitRoot, "TASK.md")
|
|
39
|
+
const taskExists = yield* fs.exists(taskPath)
|
|
40
|
+
if (!taskExists) {
|
|
41
|
+
return yield* Effect.fail(
|
|
42
|
+
new Error("TASK.md not found. Run 'agency task' first to create it."),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
verboseLog(`Found TASK.md at: ${taskPath}`)
|
|
47
|
+
|
|
48
|
+
// Change to git root before executing
|
|
49
|
+
process.chdir(gitRoot)
|
|
50
|
+
|
|
51
|
+
// Check for conflicting flags
|
|
52
|
+
if (options.opencode && options.claude) {
|
|
53
|
+
return yield* Effect.fail(
|
|
54
|
+
new Error(
|
|
55
|
+
"Cannot use both --opencode and --claude flags together. Choose one.",
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check which CLI tool is available
|
|
61
|
+
const hasOpencode = yield* Effect.tryPromise({
|
|
62
|
+
try: async () => {
|
|
63
|
+
const result = Bun.spawnSync(["which", "opencode"], {
|
|
64
|
+
stdout: "ignore",
|
|
65
|
+
stderr: "ignore",
|
|
66
|
+
})
|
|
67
|
+
return result.exitCode === 0
|
|
68
|
+
},
|
|
69
|
+
catch: () => false,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const hasClaude = yield* Effect.tryPromise({
|
|
73
|
+
try: async () => {
|
|
74
|
+
const result = Bun.spawnSync(["which", "claude"], {
|
|
75
|
+
stdout: "ignore",
|
|
76
|
+
stderr: "ignore",
|
|
77
|
+
})
|
|
78
|
+
return result.exitCode === 0
|
|
79
|
+
},
|
|
80
|
+
catch: () => false,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Determine which CLI to use based on flags or auto-detection
|
|
84
|
+
let useOpencode: boolean
|
|
85
|
+
if (options.opencode) {
|
|
86
|
+
if (!hasOpencode) {
|
|
87
|
+
return yield* Effect.fail(
|
|
88
|
+
new Error(
|
|
89
|
+
"opencode CLI tool not found. Please install OpenCode or remove the --opencode flag.",
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
useOpencode = true
|
|
94
|
+
verboseLog("Using opencode (explicitly requested)")
|
|
95
|
+
} else if (options.claude) {
|
|
96
|
+
if (!hasClaude) {
|
|
97
|
+
return yield* Effect.fail(
|
|
98
|
+
new Error(
|
|
99
|
+
"claude CLI tool not found. Please install Claude Code or remove the --claude flag.",
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
useOpencode = false
|
|
104
|
+
verboseLog("Using claude (explicitly requested)")
|
|
105
|
+
} else {
|
|
106
|
+
// Auto-detect
|
|
107
|
+
if (!hasOpencode && !hasClaude) {
|
|
108
|
+
return yield* Effect.fail(
|
|
109
|
+
new Error(
|
|
110
|
+
"Neither opencode nor claude CLI tool found. Please install OpenCode or Claude Code.",
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
useOpencode = hasOpencode
|
|
115
|
+
verboseLog(`Using ${useOpencode ? "opencode" : "claude"} (auto-detected)`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const cliName = useOpencode ? "opencode" : "claude"
|
|
119
|
+
const baseArgs = useOpencode
|
|
120
|
+
? [cliName, "-p", "Start the task"]
|
|
121
|
+
: [cliName, "Start the task"]
|
|
122
|
+
|
|
123
|
+
// Append extra args if provided
|
|
124
|
+
const cliArgs =
|
|
125
|
+
options.extraArgs && options.extraArgs.length > 0
|
|
126
|
+
? [...baseArgs, ...options.extraArgs]
|
|
127
|
+
: baseArgs
|
|
128
|
+
|
|
129
|
+
if (options.extraArgs && options.extraArgs.length > 0) {
|
|
130
|
+
verboseLog(
|
|
131
|
+
`Running ${cliName} with extra args: ${options.extraArgs.join(" ")}`,
|
|
132
|
+
)
|
|
133
|
+
} else {
|
|
134
|
+
verboseLog(`Running ${cliName} with task prompt...`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// For testing, we need to use spawn instead of exec since exec never returns
|
|
138
|
+
if (options._noExec) {
|
|
139
|
+
const result = yield* spawnProcess(cliArgs, {
|
|
140
|
+
cwd: gitRoot,
|
|
141
|
+
stdout: "inherit",
|
|
142
|
+
stderr: "inherit",
|
|
143
|
+
}).pipe(
|
|
144
|
+
Effect.catchAll((error) =>
|
|
145
|
+
Effect.fail(
|
|
146
|
+
new Error(`${cliName} exited with code ${error.exitCode}`),
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if (result.exitCode !== 0) {
|
|
152
|
+
return yield* Effect.fail(
|
|
153
|
+
new Error(`${cliName} exited with code ${result.exitCode}`),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
// Use execvp to replace the current process with the CLI tool
|
|
158
|
+
// This will never return - the process is completely replaced
|
|
159
|
+
execvp(cliName, cliArgs)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
export const help = `
|
|
164
|
+
Usage: agency work [options] [-- extra-args...]
|
|
165
|
+
|
|
166
|
+
Start working on the task described in TASK.md using OpenCode or Claude Code.
|
|
167
|
+
|
|
168
|
+
This command replaces the current process with OpenCode (if available) or
|
|
169
|
+
Claude Code (if OpenCode is not available), launching it with a prompt to
|
|
170
|
+
get started on the task described in your TASK.md file.
|
|
171
|
+
|
|
172
|
+
Options:
|
|
173
|
+
--opencode Force use of OpenCode CLI
|
|
174
|
+
--claude Force use of Claude Code CLI
|
|
175
|
+
|
|
176
|
+
Pass-through Arguments:
|
|
177
|
+
Use -- to pass additional arguments to the underlying CLI tool.
|
|
178
|
+
Everything after -- will be forwarded to opencode or claude.
|
|
179
|
+
|
|
180
|
+
Examples:
|
|
181
|
+
agency work # Auto-detect (prefers opencode)
|
|
182
|
+
agency work --opencode # Explicitly use OpenCode
|
|
183
|
+
agency work --claude # Explicitly use Claude Code
|
|
184
|
+
agency work -- --model claude-sonnet-4-20250514 # Pass custom args to CLI
|
|
185
|
+
|
|
186
|
+
Notes:
|
|
187
|
+
- Requires TASK.md to exist (run 'agency task' first)
|
|
188
|
+
- Requires either opencode or claude to be installed and available in PATH
|
|
189
|
+
- By default, prefers opencode if both are available
|
|
190
|
+
- Use --opencode or --claude to override auto-detection
|
|
191
|
+
- Arguments after -- are passed directly to the underlying tool
|
|
192
|
+
- Replaces the current process (agency exits and the tool takes over)
|
|
193
|
+
`
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Data } from "effect"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error thrown when a command requires the repository to be initialized
|
|
5
|
+
* but agency.template is not set in git config
|
|
6
|
+
*/
|
|
7
|
+
export class RepositoryNotInitializedError extends Data.TaggedError(
|
|
8
|
+
"RepositoryNotInitializedError",
|
|
9
|
+
)<{
|
|
10
|
+
readonly message: string
|
|
11
|
+
}> {
|
|
12
|
+
constructor(
|
|
13
|
+
message: string = "Repository not initialized. Run 'agency init' first to select a template.",
|
|
14
|
+
) {
|
|
15
|
+
super({ message })
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Schema } from "@effect/schema"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for managed files in the agency system
|
|
5
|
+
*/
|
|
6
|
+
export class ManagedFile extends Schema.Class<ManagedFile>("ManagedFile")({
|
|
7
|
+
name: Schema.String,
|
|
8
|
+
defaultContent: Schema.optional(Schema.String),
|
|
9
|
+
}) {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Schema for agency metadata stored in agency.json
|
|
13
|
+
*/
|
|
14
|
+
export class AgencyMetadata extends Schema.Class<AgencyMetadata>(
|
|
15
|
+
"AgencyMetadata",
|
|
16
|
+
)({
|
|
17
|
+
version: Schema.Literal(1),
|
|
18
|
+
injectedFiles: Schema.Array(Schema.String),
|
|
19
|
+
baseBranch: Schema.optional(Schema.String),
|
|
20
|
+
template: Schema.String,
|
|
21
|
+
createdAt: Schema.DateTimeUtc,
|
|
22
|
+
emitBranch: Schema.optional(Schema.String),
|
|
23
|
+
}) {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Schema for agency configuration stored in ~/.config/agency/agency.json
|
|
27
|
+
*/
|
|
28
|
+
export class AgencyConfig extends Schema.Class<AgencyConfig>("AgencyConfig")({
|
|
29
|
+
sourceBranchPattern: Schema.String.pipe(
|
|
30
|
+
Schema.annotations({ default: "agency/%branch%" }),
|
|
31
|
+
),
|
|
32
|
+
emitBranch: Schema.String.pipe(Schema.annotations({ default: "%branch%" })),
|
|
33
|
+
}) {}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { Context, Data, Effect, Layer } from "effect"
|
|
3
|
+
import { Schema } from "@effect/schema"
|
|
4
|
+
import { AgencyMetadata } from "../schemas"
|
|
5
|
+
import { FileSystemService } from "./FileSystemService"
|
|
6
|
+
import { GitService } from "./GitService"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Error type for AgencyMetadata operations
|
|
10
|
+
*/
|
|
11
|
+
export class AgencyMetadataError extends Data.TaggedError(
|
|
12
|
+
"AgencyMetadataError",
|
|
13
|
+
)<{
|
|
14
|
+
message: string
|
|
15
|
+
cause?: unknown
|
|
16
|
+
}> {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Service for managing agency.json metadata operations
|
|
20
|
+
*/
|
|
21
|
+
export class AgencyMetadataService extends Context.Tag("AgencyMetadataService")<
|
|
22
|
+
AgencyMetadataService,
|
|
23
|
+
{
|
|
24
|
+
/**
|
|
25
|
+
* Read agency.json from disk in the repository root.
|
|
26
|
+
* Returns null if the file doesn't exist or is invalid.
|
|
27
|
+
*/
|
|
28
|
+
readonly readFromDisk: (
|
|
29
|
+
gitRoot: string,
|
|
30
|
+
) => Effect.Effect<AgencyMetadata | null, never, FileSystemService>
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read agency.json from a specific git branch using git show.
|
|
34
|
+
* Returns null if the file doesn't exist or is invalid.
|
|
35
|
+
*/
|
|
36
|
+
readonly readFromBranch: (
|
|
37
|
+
gitRoot: string,
|
|
38
|
+
branch: string,
|
|
39
|
+
) => Effect.Effect<AgencyMetadata | null, never, GitService>
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Write agency.json to disk in the repository root.
|
|
43
|
+
*/
|
|
44
|
+
readonly write: (
|
|
45
|
+
gitRoot: string,
|
|
46
|
+
metadata: AgencyMetadata,
|
|
47
|
+
) => Effect.Effect<void, AgencyMetadataError, FileSystemService>
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get list of files to filter during PR/merge operations.
|
|
51
|
+
* Always includes TASK.md, AGENCY.md, and agency.json, plus any injectedFiles from metadata.
|
|
52
|
+
*/
|
|
53
|
+
readonly getFilesToFilter: (
|
|
54
|
+
gitRoot: string,
|
|
55
|
+
) => Effect.Effect<string[], never, FileSystemService>
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the configured base branch from agency.json metadata.
|
|
59
|
+
* Returns null if no metadata exists or no base branch is configured.
|
|
60
|
+
*/
|
|
61
|
+
readonly getBaseBranch: (
|
|
62
|
+
gitRoot: string,
|
|
63
|
+
) => Effect.Effect<string | null, never, FileSystemService>
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Set the base branch in agency.json metadata.
|
|
67
|
+
*/
|
|
68
|
+
readonly setBaseBranch: (
|
|
69
|
+
gitRoot: string,
|
|
70
|
+
baseBranch: string,
|
|
71
|
+
) => Effect.Effect<void, AgencyMetadataError, FileSystemService>
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse and validate agency.json content from a JSON string.
|
|
75
|
+
* Returns null if the content is invalid.
|
|
76
|
+
*/
|
|
77
|
+
readonly parse: (content: string) => Effect.Effect<AgencyMetadata | null>
|
|
78
|
+
}
|
|
79
|
+
>() {}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse and validate agency.json content from a JSON string.
|
|
83
|
+
* Returns null if the content is invalid.
|
|
84
|
+
*/
|
|
85
|
+
const parseAgencyMetadata = (content: string) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const data = yield* Effect.try({
|
|
88
|
+
try: () => JSON.parse(content),
|
|
89
|
+
catch: () => new Error("Failed to parse agency.json"),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Validate version
|
|
93
|
+
if (typeof data.version !== "number" || data.version !== 1) {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Parse and validate using Effect schema
|
|
98
|
+
const metadata = yield* Effect.try({
|
|
99
|
+
try: () => Schema.decodeUnknownSync(AgencyMetadata)(data),
|
|
100
|
+
catch: () => new Error("Invalid agency.json format"),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return metadata
|
|
104
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Implementation of AgencyMetadataService
|
|
108
|
+
*/
|
|
109
|
+
export const AgencyMetadataServiceLive = Layer.succeed(
|
|
110
|
+
AgencyMetadataService,
|
|
111
|
+
AgencyMetadataService.of({
|
|
112
|
+
readFromDisk: (gitRoot: string) =>
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
const fs = yield* FileSystemService
|
|
115
|
+
const metadataPath = join(gitRoot, "agency.json")
|
|
116
|
+
|
|
117
|
+
const exists = yield* fs.exists(metadataPath)
|
|
118
|
+
if (!exists) {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const content = yield* fs.readFile(metadataPath)
|
|
123
|
+
return yield* parseAgencyMetadata(content)
|
|
124
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null))),
|
|
125
|
+
|
|
126
|
+
readFromBranch: (gitRoot: string, branch: string) =>
|
|
127
|
+
Effect.gen(function* () {
|
|
128
|
+
const git = yield* GitService
|
|
129
|
+
|
|
130
|
+
// Try to read agency.json from the branch using git show
|
|
131
|
+
const result = yield* git.runGitCommand(
|
|
132
|
+
["git", "show", `${branch}:agency.json`],
|
|
133
|
+
gitRoot,
|
|
134
|
+
{ captureOutput: true },
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if (result.exitCode !== 0 || !result.stdout) {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return yield* parseAgencyMetadata(result.stdout)
|
|
142
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null))),
|
|
143
|
+
|
|
144
|
+
write: (gitRoot: string, metadata: AgencyMetadata) =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const fs = yield* FileSystemService
|
|
147
|
+
const metadataPath = join(gitRoot, "agency.json")
|
|
148
|
+
|
|
149
|
+
const content = JSON.stringify(metadata, null, 2) + "\n"
|
|
150
|
+
yield* fs.writeFile(metadataPath, content).pipe(
|
|
151
|
+
Effect.mapError(
|
|
152
|
+
(error) =>
|
|
153
|
+
new AgencyMetadataError({
|
|
154
|
+
message: `Failed to write agency.json: ${error}`,
|
|
155
|
+
cause: error,
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
}),
|
|
160
|
+
|
|
161
|
+
getFilesToFilter: (gitRoot: string) =>
|
|
162
|
+
Effect.gen(function* () {
|
|
163
|
+
const fs = yield* FileSystemService
|
|
164
|
+
const metadataPath = join(gitRoot, "agency.json")
|
|
165
|
+
|
|
166
|
+
const exists = yield* fs.exists(metadataPath)
|
|
167
|
+
const baseFiles = ["TASK.md", "AGENCY.md", "agency.json"]
|
|
168
|
+
|
|
169
|
+
if (!exists) {
|
|
170
|
+
return baseFiles
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const content = yield* fs
|
|
174
|
+
.readFile(metadataPath)
|
|
175
|
+
.pipe(Effect.catchAll(() => Effect.succeed("")))
|
|
176
|
+
|
|
177
|
+
if (!content) {
|
|
178
|
+
return baseFiles
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const metadata = yield* parseAgencyMetadata(content)
|
|
182
|
+
|
|
183
|
+
if (!metadata) {
|
|
184
|
+
return baseFiles
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return [...baseFiles, ...metadata.injectedFiles]
|
|
188
|
+
}).pipe(
|
|
189
|
+
Effect.catchAll(() =>
|
|
190
|
+
Effect.succeed(["TASK.md", "AGENCY.md", "agency.json"]),
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
|
|
194
|
+
getBaseBranch: (gitRoot: string) =>
|
|
195
|
+
Effect.gen(function* () {
|
|
196
|
+
const fs = yield* FileSystemService
|
|
197
|
+
const metadataPath = join(gitRoot, "agency.json")
|
|
198
|
+
|
|
199
|
+
const exists = yield* fs.exists(metadataPath)
|
|
200
|
+
if (!exists) {
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const content = yield* fs
|
|
205
|
+
.readFile(metadataPath)
|
|
206
|
+
.pipe(Effect.catchAll(() => Effect.succeed("")))
|
|
207
|
+
|
|
208
|
+
if (!content) {
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const metadata = yield* parseAgencyMetadata(content)
|
|
213
|
+
return metadata?.baseBranch || null
|
|
214
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null))),
|
|
215
|
+
|
|
216
|
+
setBaseBranch: (gitRoot: string, baseBranch: string) =>
|
|
217
|
+
Effect.gen(function* () {
|
|
218
|
+
const fs = yield* FileSystemService
|
|
219
|
+
const metadataPath = join(gitRoot, "agency.json")
|
|
220
|
+
|
|
221
|
+
const exists = yield* fs.exists(metadataPath).pipe(
|
|
222
|
+
Effect.mapError(
|
|
223
|
+
(error) =>
|
|
224
|
+
new AgencyMetadataError({
|
|
225
|
+
message: `Failed to check agency.json: ${error}`,
|
|
226
|
+
cause: error,
|
|
227
|
+
}),
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if (!exists) {
|
|
232
|
+
return yield* Effect.fail(
|
|
233
|
+
new AgencyMetadataError({
|
|
234
|
+
message:
|
|
235
|
+
"agency.json not found. Please run 'agency task' first to initialize backpack files.",
|
|
236
|
+
}),
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const content = yield* fs.readFile(metadataPath).pipe(
|
|
241
|
+
Effect.mapError(
|
|
242
|
+
(error) =>
|
|
243
|
+
new AgencyMetadataError({
|
|
244
|
+
message: `Failed to read agency.json: ${error}`,
|
|
245
|
+
cause: error,
|
|
246
|
+
}),
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const metadata = yield* parseAgencyMetadata(content).pipe(
|
|
251
|
+
Effect.flatMap((m) =>
|
|
252
|
+
m
|
|
253
|
+
? Effect.succeed(m)
|
|
254
|
+
: Effect.fail(
|
|
255
|
+
new AgencyMetadataError({
|
|
256
|
+
message:
|
|
257
|
+
"agency.json is invalid. Please run 'agency task' first to initialize backpack files.",
|
|
258
|
+
}),
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
// Create a new metadata instance with the updated baseBranch
|
|
264
|
+
const updatedMetadata = new AgencyMetadata({
|
|
265
|
+
version: metadata.version,
|
|
266
|
+
injectedFiles: metadata.injectedFiles,
|
|
267
|
+
template: metadata.template,
|
|
268
|
+
createdAt: metadata.createdAt,
|
|
269
|
+
baseBranch,
|
|
270
|
+
emitBranch: metadata.emitBranch,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
const outputContent = JSON.stringify(updatedMetadata, null, 2) + "\n"
|
|
274
|
+
return yield* fs.writeFile(metadataPath, outputContent).pipe(
|
|
275
|
+
Effect.mapError(
|
|
276
|
+
(error) =>
|
|
277
|
+
new AgencyMetadataError({
|
|
278
|
+
message: `Failed to write agency.json: ${error}`,
|
|
279
|
+
cause: error,
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
}),
|
|
284
|
+
|
|
285
|
+
parse: parseAgencyMetadata,
|
|
286
|
+
}),
|
|
287
|
+
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { join } from "path"
|
|
4
|
+
import {
|
|
5
|
+
createTempDir,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
initGitRepo,
|
|
8
|
+
runTestEffect,
|
|
9
|
+
fileExists,
|
|
10
|
+
readFile,
|
|
11
|
+
createFile,
|
|
12
|
+
} from "../test-utils"
|
|
13
|
+
import { ClaudeService } from "./ClaudeService"
|
|
14
|
+
|
|
15
|
+
describe("ClaudeService", () => {
|
|
16
|
+
let tempDir: string
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
tempDir = await createTempDir()
|
|
20
|
+
await initGitRepo(tempDir)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await cleanupTempDir(tempDir)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe("claudeFileExists", () => {
|
|
28
|
+
test("returns false when CLAUDE.md does not exist", async () => {
|
|
29
|
+
const result = await runTestEffect(
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const claudeService = yield* ClaudeService
|
|
32
|
+
return yield* claudeService.claudeFileExists(tempDir)
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
expect(result).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("returns true when CLAUDE.md exists", async () => {
|
|
40
|
+
await createFile(tempDir, "CLAUDE.md", "# Claude Code\n")
|
|
41
|
+
|
|
42
|
+
const result = await runTestEffect(
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
const claudeService = yield* ClaudeService
|
|
45
|
+
return yield* claudeService.claudeFileExists(tempDir)
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
expect(result).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe("hasAgencySection", () => {
|
|
54
|
+
test("returns false when content does not have agency section", async () => {
|
|
55
|
+
const content = "# Claude Code\n\nSome instructions\n"
|
|
56
|
+
|
|
57
|
+
const result = await runTestEffect(
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const claudeService = yield* ClaudeService
|
|
60
|
+
return yield* claudeService.hasAgencySection(content)
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(result).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("returns true when content has both @AGENCY.md and @TASK.md", async () => {
|
|
68
|
+
const content = `# Claude Code
|
|
69
|
+
|
|
70
|
+
Some instructions
|
|
71
|
+
|
|
72
|
+
## Agency
|
|
73
|
+
|
|
74
|
+
@AGENCY.md
|
|
75
|
+
@TASK.md
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
const result = await runTestEffect(
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const claudeService = yield* ClaudeService
|
|
81
|
+
return yield* claudeService.hasAgencySection(content)
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(result).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("injectAgencySection", () => {
|
|
90
|
+
test("creates CLAUDE.md with agency section when file does not exist", async () => {
|
|
91
|
+
const result = await runTestEffect(
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
const claudeService = yield* ClaudeService
|
|
94
|
+
return yield* claudeService.injectAgencySection(tempDir)
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(result.created).toBe(true)
|
|
99
|
+
expect(result.modified).toBe(true)
|
|
100
|
+
|
|
101
|
+
const claudePath = join(tempDir, "CLAUDE.md")
|
|
102
|
+
expect(await fileExists(claudePath)).toBe(true)
|
|
103
|
+
|
|
104
|
+
const content = await readFile(claudePath)
|
|
105
|
+
expect(content).toContain("@AGENCY.md")
|
|
106
|
+
expect(content).toContain("@TASK.md")
|
|
107
|
+
expect(content.indexOf("@AGENCY.md")).toBeLessThan(
|
|
108
|
+
content.indexOf("@TASK.md"),
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("appends agency section when file exists without references", async () => {
|
|
113
|
+
const initialContent = "# Claude Code\n\nSome existing instructions\n"
|
|
114
|
+
await createFile(tempDir, "CLAUDE.md", initialContent)
|
|
115
|
+
|
|
116
|
+
const result = await runTestEffect(
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const claudeService = yield* ClaudeService
|
|
119
|
+
return yield* claudeService.injectAgencySection(tempDir)
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
expect(result.created).toBe(false)
|
|
124
|
+
expect(result.modified).toBe(true)
|
|
125
|
+
|
|
126
|
+
const content = await readFile(join(tempDir, "CLAUDE.md"))
|
|
127
|
+
expect(content).toContain("Some existing instructions")
|
|
128
|
+
expect(content).toContain("@AGENCY.md")
|
|
129
|
+
expect(content).toContain("@TASK.md")
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("does not modify file when agency section already exists in correct order", async () => {
|
|
133
|
+
const contentWithSection = `# Claude Code
|
|
134
|
+
|
|
135
|
+
Some instructions
|
|
136
|
+
|
|
137
|
+
## Agency
|
|
138
|
+
|
|
139
|
+
@AGENCY.md
|
|
140
|
+
@TASK.md
|
|
141
|
+
`
|
|
142
|
+
await createFile(tempDir, "CLAUDE.md", contentWithSection)
|
|
143
|
+
|
|
144
|
+
const result = await runTestEffect(
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const claudeService = yield* ClaudeService
|
|
147
|
+
return yield* claudeService.injectAgencySection(tempDir)
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
expect(result.created).toBe(false)
|
|
152
|
+
expect(result.modified).toBe(false)
|
|
153
|
+
|
|
154
|
+
const content = await readFile(join(tempDir, "CLAUDE.md"))
|
|
155
|
+
expect(content).toBe(contentWithSection)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("re-adds references when they exist in wrong order", async () => {
|
|
159
|
+
const contentWithWrongOrder = `# Claude Code
|
|
160
|
+
|
|
161
|
+
Some instructions
|
|
162
|
+
|
|
163
|
+
@TASK.md
|
|
164
|
+
@AGENCY.md
|
|
165
|
+
`
|
|
166
|
+
await createFile(tempDir, "CLAUDE.md", contentWithWrongOrder)
|
|
167
|
+
|
|
168
|
+
const result = await runTestEffect(
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const claudeService = yield* ClaudeService
|
|
171
|
+
return yield* claudeService.injectAgencySection(tempDir)
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
expect(result.created).toBe(false)
|
|
176
|
+
expect(result.modified).toBe(true)
|
|
177
|
+
|
|
178
|
+
const content = await readFile(join(tempDir, "CLAUDE.md"))
|
|
179
|
+
// Should have the section appended with correct order
|
|
180
|
+
expect(content).toContain("@AGENCY.md")
|
|
181
|
+
expect(content).toContain("@TASK.md")
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
})
|