@markjaquith/agency 1.11.0 → 2.0.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 (130) hide show
  1. package/README.md +302 -113
  2. package/cli.ts +123 -415
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +258 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +172 -419
  24. package/src/commands/work.ts +47 -166
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/worktree-command.test.ts +61 -0
  53. package/src/workbase/worktree-command.ts +52 -0
  54. package/src/commands/base.test.ts +0 -198
  55. package/src/commands/base.ts +0 -198
  56. package/src/commands/clean.test.ts +0 -299
  57. package/src/commands/clean.ts +0 -274
  58. package/src/commands/completions.test.ts +0 -51
  59. package/src/commands/completions.ts +0 -308
  60. package/src/commands/emit.integration.test.ts +0 -549
  61. package/src/commands/emit.test.ts +0 -576
  62. package/src/commands/emit.ts +0 -700
  63. package/src/commands/emitted.test.ts +0 -226
  64. package/src/commands/emitted.ts +0 -57
  65. package/src/commands/loop.test.ts +0 -621
  66. package/src/commands/loop.ts +0 -406
  67. package/src/commands/merge.integration.test.ts +0 -195
  68. package/src/commands/merge.test.ts +0 -246
  69. package/src/commands/merge.ts +0 -244
  70. package/src/commands/pull.test.ts +0 -395
  71. package/src/commands/pull.ts +0 -205
  72. package/src/commands/push.test.ts +0 -518
  73. package/src/commands/push.ts +0 -434
  74. package/src/commands/rebase.test.ts +0 -521
  75. package/src/commands/rebase.ts +0 -232
  76. package/src/commands/save.test.ts +0 -247
  77. package/src/commands/save.ts +0 -162
  78. package/src/commands/source.test.ts +0 -195
  79. package/src/commands/source.ts +0 -72
  80. package/src/commands/switch.test.ts +0 -194
  81. package/src/commands/switch.ts +0 -84
  82. package/src/commands/task-branching.test.ts +0 -633
  83. package/src/commands/task-continue.test.ts +0 -311
  84. package/src/commands/task-edit.test.ts +0 -235
  85. package/src/commands/task-main.test.ts +0 -1066
  86. package/src/commands/task-squash.test.ts +0 -266
  87. package/src/commands/tasks.test.ts +0 -391
  88. package/src/commands/tasks.ts +0 -161
  89. package/src/commands/template-delete.test.ts +0 -178
  90. package/src/commands/template-delete.ts +0 -98
  91. package/src/commands/template-list.test.ts +0 -135
  92. package/src/commands/template-list.ts +0 -87
  93. package/src/commands/template-view.test.ts +0 -158
  94. package/src/commands/template-view.ts +0 -86
  95. package/src/commands/template.test.ts +0 -32
  96. package/src/commands/template.ts +0 -96
  97. package/src/commands/use.test.ts +0 -87
  98. package/src/commands/use.ts +0 -97
  99. package/src/constants.ts +0 -10
  100. package/src/errors.ts +0 -17
  101. package/src/schemas.ts +0 -33
  102. package/src/services/AgencyMetadataService.ts +0 -311
  103. package/src/services/ClaudeService.test.ts +0 -184
  104. package/src/services/ClaudeService.ts +0 -91
  105. package/src/services/ConfigService.ts +0 -115
  106. package/src/services/FilterRepoService.ts +0 -164
  107. package/src/services/FormatterService.test.ts +0 -432
  108. package/src/services/FormatterService.ts +0 -219
  109. package/src/services/GitService.ts +0 -1183
  110. package/src/services/MockFilterRepoService.ts +0 -140
  111. package/src/services/OpencodeService.ts +0 -263
  112. package/src/services/PromptService.ts +0 -183
  113. package/src/services/TemplateService.ts +0 -81
  114. package/src/types/native-exec.d.ts +0 -8
  115. package/src/utils/colors.ts +0 -178
  116. package/src/utils/git-path.ts +0 -6
  117. package/src/utils/glob.test.ts +0 -161
  118. package/src/utils/glob.ts +0 -78
  119. package/src/utils/paths.ts +0 -51
  120. package/src/utils/pr-branch.test.ts +0 -376
  121. package/src/utils/pr-branch.ts +0 -508
  122. package/src/utils/spinner.test.ts +0 -45
  123. package/src/utils/spinner.ts +0 -96
  124. package/src/utils/task-parser.test.ts +0 -172
  125. package/src/utils/task-parser.ts +0 -93
  126. package/templates/AGENCY.md +0 -22
  127. package/templates/AGENTS.md +0 -11
  128. package/templates/CLAUDE.md +0 -3
  129. package/templates/TASK.md +0 -5
  130. package/templates/opencode.json +0 -4
@@ -1,193 +1,74 @@
1
- import { join } from "node:path"
2
1
  import { Effect } from "effect"
3
2
  import type { BaseCommandOptions } from "../utils/command"
3
+ import { WorktreeService } from "../services/WorktreeService"
4
4
  import { FileSystemService } from "../services/FileSystemService"
5
- import { createLoggers, ensureGitRepo } from "../utils/effect"
6
- import { spawnProcess } from "../utils/process"
5
+ import { createLoggers } from "../utils/effect"
7
6
  import { execvp } from "../utils/exec"
8
7
 
9
8
  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
9
+ readonly taskId?: string
10
+ readonly phaseId?: string
11
+ readonly opencode?: boolean
12
+ readonly claude?: boolean
27
13
  }
28
14
 
29
- export const work = (options: WorkOptions = {}) =>
30
- Effect.gen(function* () {
31
- const { verboseLog } = createLoggers(options)
32
-
33
- const fs = yield* FileSystemService
15
+ type LaunchAgent = (cli: string, args: readonly string[], cwd: string) => void
34
16
 
35
- const gitRoot = yield* ensureGitRepo()
17
+ const launchAgent: LaunchAgent = (cli, args, cwd) => {
18
+ process.chdir(cwd)
19
+ execvp(cli, [...args])
20
+ }
36
21
 
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
- )
22
+ export const work = (
23
+ options: WorkOptions = {},
24
+ launch: LaunchAgent = launchAgent,
25
+ ) =>
26
+ Effect.gen(function* () {
27
+ if (!options.taskId) {
28
+ return yield* Effect.fail(new Error("Task ID is required"))
44
29
  }
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
30
  if (options.opencode && options.claude) {
53
31
  return yield* Effect.fail(
54
- new Error(
55
- "Cannot use both --opencode and --claude flags together. Choose one.",
56
- ),
32
+ new Error("Cannot use both --opencode and --claude"),
57
33
  )
58
34
  }
59
35
 
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,
36
+ const worktrees = yield* WorktreeService
37
+ const fs = yield* FileSystemService
38
+ const { verboseLog } = createLoggers(options)
39
+ const workspace = yield* worktrees.materialize(
40
+ options.taskId,
41
+ options.phaseId,
42
+ options.cwd ?? process.cwd(),
43
+ )
44
+ const prompt = workspace.phasePath
45
+ ? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
46
+ : `Start the task. Read ${workspace.taskPath}.`
47
+
48
+ const requested = options.claude ? "claude" : "opencode"
49
+ let cli = requested
50
+ let available = yield* fs.runCommand(["which", cli], {
51
+ captureOutput: true,
81
52
  })
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)`)
53
+ if (available.exitCode !== 0 && !options.opencode && !options.claude) {
54
+ cli = "claude"
55
+ available = yield* fs.runCommand(["which", cli], { captureOutput: true })
116
56
  }
117
-
118
- const cliName = useOpencode ? "opencode" : "claude"
119
- const baseArgs = useOpencode
120
- ? [cliName, "--prompt", "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...`)
57
+ if (available.exitCode !== 0) {
58
+ return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
135
59
  }
136
60
 
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
- }
61
+ verboseLog(`Launching ${cli} in ${workspace.writablePath}`)
62
+ const args = cli === "opencode" ? ["--prompt", prompt] : [prompt]
63
+ launch(cli, [cli, ...args], workspace.writablePath)
161
64
  })
162
65
 
163
66
  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.
67
+ Usage: agency work <task-id> [phase-id]
167
68
 
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.
69
+ Materialize repository worktrees and launch an agent in the writable checkout.
171
70
 
172
71
  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)
72
+ --opencode Require OpenCode
73
+ --claude Require Claude Code
193
74
  `
@@ -0,0 +1,71 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { mkdir } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
5
+ import { Effect } from "effect"
6
+ import { EpicService } from "./EpicService"
7
+
8
+ describe("EpicService", () => {
9
+ let root: string
10
+
11
+ beforeEach(async () => {
12
+ root = await createTempDir()
13
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
14
+ await mkdir(join(root, "repos/agency"), { recursive: true })
15
+ })
16
+
17
+ afterEach(async () => {
18
+ await cleanupTempDir(root)
19
+ })
20
+
21
+ test("creates, lists, and shows epics", async () => {
22
+ const created = await runTestEffect(
23
+ EpicService.pipe(
24
+ Effect.flatMap((service) =>
25
+ service.create(
26
+ "workspace-orchestration",
27
+ "https://example.com/tickets/epic",
28
+ [{ repo: "agency", ref: "main" }],
29
+ root,
30
+ ),
31
+ ),
32
+ ),
33
+ )
34
+
35
+ expect(created.content).toContain("ticketUrl:")
36
+ expect(created.content).toContain("tasks: []")
37
+
38
+ const records = await runTestEffect(
39
+ EpicService.pipe(Effect.flatMap((service) => service.list(root))),
40
+ )
41
+ expect(records.map((record) => record.id)).toEqual([
42
+ "workspace-orchestration",
43
+ ])
44
+
45
+ const shown = await runTestEffect(
46
+ EpicService.pipe(
47
+ Effect.flatMap((service) =>
48
+ service.show("workspace-orchestration", root),
49
+ ),
50
+ ),
51
+ )
52
+ expect(shown.path).toBe(join(root, "epics/workspace-orchestration/EPIC.md"))
53
+ })
54
+
55
+ test("rejects unknown repository aliases", async () => {
56
+ await expect(
57
+ runTestEffect(
58
+ EpicService.pipe(
59
+ Effect.flatMap((service) =>
60
+ service.create(
61
+ "example",
62
+ "https://example.com/tickets/epic",
63
+ [{ repo: "missing", ref: "main" }],
64
+ root,
65
+ ),
66
+ ),
67
+ ),
68
+ ),
69
+ ).rejects.toThrow("Unknown repository alias 'missing'")
70
+ })
71
+ })
@@ -0,0 +1,135 @@
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { join } from "node:path"
4
+ import { FileSystemService } from "./FileSystemService"
5
+ import { WorkbaseService } from "./WorkbaseService"
6
+ import {
7
+ EpicFrontmatter,
8
+ EntityId,
9
+ type RepositoryReference,
10
+ } from "../workbase/schemas"
11
+ import {
12
+ formatMarkdownDocument,
13
+ parseFrontmatter,
14
+ } from "../workbase/frontmatter"
15
+
16
+ class EpicError extends Data.TaggedError("EpicError")<{
17
+ readonly message: string
18
+ }> {}
19
+
20
+ export interface EpicRecord {
21
+ readonly id: string
22
+ readonly path: string
23
+ readonly content: string
24
+ readonly data: Schema.Schema.Type<typeof EpicFrontmatter>
25
+ }
26
+
27
+ const decodeEpic = (input: unknown) => {
28
+ const result = Schema.decodeUnknownEither(EpicFrontmatter, {
29
+ errors: "all",
30
+ onExcessProperty: "error",
31
+ })(input)
32
+ return Either.isLeft(result)
33
+ ? Effect.fail(
34
+ new EpicError({ message: TreeFormatter.formatErrorSync(result.left) }),
35
+ )
36
+ : Effect.succeed(result.right)
37
+ }
38
+
39
+ const decodeId = (id: string) => {
40
+ const result = Schema.decodeUnknownEither(EntityId)(id)
41
+ return Either.isLeft(result)
42
+ ? Effect.fail(new EpicError({ message: `Invalid epic ID '${id}'` }))
43
+ : Effect.succeed(result.right)
44
+ }
45
+
46
+ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
47
+ sync: () => ({
48
+ create: (
49
+ id: string,
50
+ ticketUrl: string,
51
+ repos: readonly RepositoryReference[],
52
+ startPath: string = process.cwd(),
53
+ description?: string,
54
+ ) =>
55
+ Effect.gen(function* () {
56
+ const fs = yield* FileSystemService
57
+ const workbase = yield* WorkbaseService
58
+ const root = yield* workbase.discover(startPath)
59
+ const validId = yield* decodeId(id)
60
+ const data = yield* decodeEpic({
61
+ ticketUrl,
62
+ ...(description !== undefined ? { description } : {}),
63
+ repos,
64
+ tasks: [],
65
+ })
66
+ const directory = join(root, "epics", validId)
67
+ const path = join(directory, "EPIC.md")
68
+
69
+ if (yield* fs.exists(directory)) {
70
+ return yield* new EpicError({
71
+ message: `Epic '${validId}' already exists`,
72
+ })
73
+ }
74
+
75
+ for (const { repo: alias } of data.repos) {
76
+ if (!(yield* fs.exists(join(root, "repos", alias)))) {
77
+ return yield* new EpicError({
78
+ message: `Unknown repository alias '${alias}'`,
79
+ })
80
+ }
81
+ }
82
+
83
+ yield* fs.createDirectory(directory)
84
+ const title = validId
85
+ .split("-")
86
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
87
+ .join(" ")
88
+ const content = formatMarkdownDocument(
89
+ data,
90
+ `# ${title}\n\nDescribe the epic outcome.`,
91
+ )
92
+ yield* fs.writeFile(path, content)
93
+ return { id: validId, path, content, data } satisfies EpicRecord
94
+ }),
95
+
96
+ list: (startPath: string = process.cwd()) =>
97
+ Effect.gen(function* () {
98
+ const fs = yield* FileSystemService
99
+ const workbase = yield* WorkbaseService
100
+ const root = yield* workbase.discover(startPath)
101
+ const directory = join(root, "epics")
102
+ if (!(yield* fs.isDirectory(directory))) {
103
+ return [] as EpicRecord[]
104
+ }
105
+
106
+ const entries = (yield* fs.readDirectory(directory))
107
+ .filter((entry) => entry.isDirectory)
108
+ .sort((a, b) => a.name.localeCompare(b.name))
109
+ const records: EpicRecord[] = []
110
+ for (const entry of entries) {
111
+ const path = join(directory, entry.name, "EPIC.md")
112
+ if (!(yield* fs.exists(path))) continue
113
+ const content = yield* fs.readFile(path)
114
+ const parsed = yield* parseFrontmatter(content, path)
115
+ const data = yield* decodeEpic(parsed.data)
116
+ records.push({ id: entry.name, path, content, data })
117
+ }
118
+ return records
119
+ }),
120
+
121
+ show: (id: string, startPath: string = process.cwd()) =>
122
+ Effect.gen(function* () {
123
+ const service = yield* EpicService
124
+ const validId = yield* decodeId(id)
125
+ const records = yield* service.list(startPath)
126
+ const record = records.find((epic) => epic.id === validId)
127
+ if (!record) {
128
+ return yield* new EpicError({
129
+ message: `Epic '${validId}' does not exist`,
130
+ })
131
+ }
132
+ return record
133
+ }),
134
+ }),
135
+ }) {}
@@ -1,10 +1,13 @@
1
1
  import { Effect, Data, pipe } from "effect"
2
2
  import {
3
3
  mkdir,
4
- copyFile as fsCopyFile,
5
- unlink,
6
4
  lstat,
7
5
  readlink,
6
+ readdir,
7
+ realpath,
8
+ rename,
9
+ stat,
10
+ symlink,
8
11
  } from "node:fs/promises"
9
12
  import { spawnProcess } from "../utils/process"
10
13
 
@@ -26,8 +29,20 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
26
29
  exists: (path: string) =>
27
30
  Effect.tryPromise({
28
31
  try: async () => {
29
- const file = Bun.file(path)
30
- return await file.exists()
32
+ try {
33
+ await lstat(path)
34
+ return true
35
+ } catch (error) {
36
+ if (
37
+ typeof error === "object" &&
38
+ error !== null &&
39
+ "code" in error &&
40
+ error.code === "ENOENT"
41
+ ) {
42
+ return false
43
+ }
44
+ throw error
45
+ }
31
46
  },
32
47
  catch: () =>
33
48
  new FileSystemError({
@@ -38,16 +53,19 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
38
53
  isDirectory: (path: string) =>
39
54
  Effect.tryPromise({
40
55
  try: async () => {
41
- const file = Bun.file(path)
42
- const exists = await file.exists()
43
- if (!exists) {
44
- return false
56
+ try {
57
+ return (await stat(path)).isDirectory()
58
+ } catch (error) {
59
+ if (
60
+ typeof error === "object" &&
61
+ error !== null &&
62
+ "code" in error &&
63
+ error.code === "ENOENT"
64
+ ) {
65
+ return false
66
+ }
67
+ throw error
45
68
  }
46
- // Use stat to check if it's a directory
47
- const stat = await import("node:fs/promises").then((fs) =>
48
- fs.stat(path),
49
- )
50
- return stat.isDirectory()
51
69
  },
52
70
  catch: () =>
53
71
  new FileSystemError({
@@ -55,6 +73,16 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
55
73
  }),
56
74
  }),
57
75
 
76
+ realPath: (path: string) =>
77
+ Effect.tryPromise({
78
+ try: () => realpath(path),
79
+ catch: (error) =>
80
+ new FileSystemError({
81
+ message: `Failed to resolve path: ${path}`,
82
+ cause: error,
83
+ }),
84
+ }),
85
+
58
86
  readFile: (path: string) =>
59
87
  Effect.tryPromise({
60
88
  try: async () => {
@@ -78,19 +106,6 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
78
106
  }),
79
107
  }),
80
108
 
81
- readJSON: <T = unknown>(path: string) =>
82
- Effect.tryPromise({
83
- try: async (): Promise<T> => {
84
- const file = Bun.file(path)
85
- const exists = await file.exists()
86
- if (!exists) {
87
- throw new Error(`File not found: ${path}`)
88
- }
89
- return await file.json()
90
- },
91
- catch: () => new FileNotFoundError({ path }),
92
- }),
93
-
94
109
  writeJSON: <T = unknown>(path: string, data: T) =>
95
110
  Effect.tryPromise({
96
111
  try: () => Bun.write(path, JSON.stringify(data, null, 2) + "\n"),
@@ -111,22 +126,29 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
111
126
  }),
112
127
  }),
113
128
 
114
- deleteFile: (path: string) =>
129
+ moveDirectory: (from: string, to: string) =>
115
130
  Effect.tryPromise({
116
- try: () => unlink(path),
131
+ try: () => rename(from, to),
117
132
  catch: (error) =>
118
133
  new FileSystemError({
119
- message: `Failed to delete file: ${path}`,
134
+ message: `Failed to move directory from ${from} to ${to}`,
120
135
  cause: error,
121
136
  }),
122
137
  }),
123
138
 
124
- copyFile: (from: string, to: string) =>
139
+ readDirectory: (path: string) =>
125
140
  Effect.tryPromise({
126
- try: () => fsCopyFile(from, to),
141
+ try: async () => {
142
+ const entries = await readdir(path, { withFileTypes: true })
143
+ return entries.map((entry) => ({
144
+ name: entry.name,
145
+ isDirectory: entry.isDirectory(),
146
+ isSymlink: entry.isSymbolicLink(),
147
+ }))
148
+ },
127
149
  catch: (error) =>
128
150
  new FileSystemError({
129
- message: `Failed to copy file from ${from} to ${to}`,
151
+ message: `Failed to read directory: ${path}`,
130
152
  cause: error,
131
153
  }),
132
154
  }),
@@ -158,15 +180,16 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
158
180
  options?: {
159
181
  readonly cwd?: string
160
182
  readonly captureOutput?: boolean
161
- readonly interactive?: boolean
183
+ readonly env?: Record<string, string>
162
184
  },
163
185
  ) =>
164
186
  pipe(
165
187
  spawnProcess(args, {
166
188
  cwd: options?.cwd,
167
- stdin: options?.interactive ? "inherit" : "pipe",
189
+ stdin: "pipe",
168
190
  stdout: options?.captureOutput ? "pipe" : "inherit",
169
- stderr: options?.interactive ? "inherit" : "pipe",
191
+ stderr: "pipe",
192
+ env: options?.env,
170
193
  }),
171
194
  Effect.mapError(
172
195
  (processError) =>
@@ -177,70 +200,16 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
177
200
  ),
178
201
  ),
179
202
 
180
- /**
181
- * Recursively collect all files in a directory.
182
- * Returns paths relative to the directory (or relativeTo if specified).
183
- */
184
- collectFiles: (
185
- dirPath: string,
186
- options?: {
187
- readonly relativeTo?: string
188
- readonly exclude?: readonly string[]
189
- readonly sort?: boolean
190
- },
191
- ) =>
203
+ createSymlink: (target: string, path: string) =>
192
204
  Effect.tryPromise({
193
- try: async () => {
194
- const { relativeTo, exclude = [], sort = false } = options ?? {}
195
- const basePath = relativeTo ?? dirPath
196
-
197
- // Build find command with exclusions
198
- const findArgs = ["find", dirPath, "-type", "f"]
199
- for (const pattern of exclude) {
200
- findArgs.push("!", "-name", pattern)
201
- }
202
-
203
- const result = Bun.spawnSync(findArgs, {
204
- stdout: "pipe",
205
- stderr: "ignore",
206
- })
207
-
208
- const output = new TextDecoder().decode(result.stdout)
209
- if (!output) {
210
- return []
211
- }
212
-
213
- const files = output
214
- .trim()
215
- .split("\n")
216
- .filter((f: string) => f.length > 0)
217
- .map((file) => file.replace(basePath + "/", ""))
218
- .filter((f) => f.length > 0)
219
-
220
- return sort ? files.sort() : files
221
- },
205
+ try: () => symlink(target, path, "dir"),
222
206
  catch: (error) =>
223
207
  new FileSystemError({
224
- message: `Failed to collect files from ${dirPath}`,
208
+ message: `Failed to create symlink ${path} -> ${target}`,
225
209
  cause: error,
226
210
  }),
227
211
  }),
228
212
 
229
- /**
230
- * Check if a path is a symbolic link.
231
- * Returns false if the file doesn't exist or if it's not a symlink.
232
- */
233
- isSymlink: (path: string) =>
234
- Effect.tryPromise({
235
- try: async () => {
236
- const stats = await lstat(path)
237
- return stats.isSymbolicLink()
238
- },
239
- catch: () =>
240
- // If we can't lstat, it's not a symlink (or doesn't exist)
241
- false,
242
- }).pipe(Effect.catchAll(() => Effect.succeed(false))),
243
-
244
213
  /**
245
214
  * Read the target of a symbolic link.
246
215
  * Returns null if the file is not a symlink or doesn't exist.