@markjaquith/agency 1.11.1 → 2.1.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 (132) hide show
  1. package/README.md +313 -117
  2. package/cli.ts +124 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +266 -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 +326 -399
  24. package/src/commands/work.ts +153 -153
  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/work-target.test.ts +77 -0
  53. package/src/workbase/work-target.ts +180 -0
  54. package/src/workbase/worktree-command.test.ts +61 -0
  55. package/src/workbase/worktree-command.ts +52 -0
  56. package/src/commands/base.test.ts +0 -198
  57. package/src/commands/base.ts +0 -198
  58. package/src/commands/clean.test.ts +0 -299
  59. package/src/commands/clean.ts +0 -274
  60. package/src/commands/completions.test.ts +0 -51
  61. package/src/commands/completions.ts +0 -308
  62. package/src/commands/emit.integration.test.ts +0 -549
  63. package/src/commands/emit.test.ts +0 -576
  64. package/src/commands/emit.ts +0 -700
  65. package/src/commands/emitted.test.ts +0 -226
  66. package/src/commands/emitted.ts +0 -57
  67. package/src/commands/loop.test.ts +0 -621
  68. package/src/commands/loop.ts +0 -406
  69. package/src/commands/merge.integration.test.ts +0 -195
  70. package/src/commands/merge.test.ts +0 -246
  71. package/src/commands/merge.ts +0 -244
  72. package/src/commands/pull.test.ts +0 -395
  73. package/src/commands/pull.ts +0 -205
  74. package/src/commands/push.test.ts +0 -641
  75. package/src/commands/push.ts +0 -482
  76. package/src/commands/rebase.test.ts +0 -521
  77. package/src/commands/rebase.ts +0 -232
  78. package/src/commands/save.test.ts +0 -247
  79. package/src/commands/save.ts +0 -162
  80. package/src/commands/source.test.ts +0 -195
  81. package/src/commands/source.ts +0 -72
  82. package/src/commands/switch.test.ts +0 -194
  83. package/src/commands/switch.ts +0 -84
  84. package/src/commands/task-branching.test.ts +0 -633
  85. package/src/commands/task-continue.test.ts +0 -311
  86. package/src/commands/task-edit.test.ts +0 -235
  87. package/src/commands/task-main.test.ts +0 -1066
  88. package/src/commands/task-squash.test.ts +0 -266
  89. package/src/commands/tasks.test.ts +0 -391
  90. package/src/commands/tasks.ts +0 -161
  91. package/src/commands/template-delete.test.ts +0 -178
  92. package/src/commands/template-delete.ts +0 -98
  93. package/src/commands/template-list.test.ts +0 -135
  94. package/src/commands/template-list.ts +0 -87
  95. package/src/commands/template-view.test.ts +0 -158
  96. package/src/commands/template-view.ts +0 -86
  97. package/src/commands/template.test.ts +0 -32
  98. package/src/commands/template.ts +0 -96
  99. package/src/commands/use.test.ts +0 -87
  100. package/src/commands/use.ts +0 -97
  101. package/src/constants.ts +0 -10
  102. package/src/errors.ts +0 -17
  103. package/src/schemas.ts +0 -33
  104. package/src/services/AgencyMetadataService.ts +0 -311
  105. package/src/services/ClaudeService.test.ts +0 -184
  106. package/src/services/ClaudeService.ts +0 -91
  107. package/src/services/ConfigService.ts +0 -115
  108. package/src/services/FilterRepoService.ts +0 -164
  109. package/src/services/FormatterService.test.ts +0 -432
  110. package/src/services/FormatterService.ts +0 -219
  111. package/src/services/GitService.ts +0 -1187
  112. package/src/services/MockFilterRepoService.ts +0 -140
  113. package/src/services/OpencodeService.ts +0 -263
  114. package/src/services/PromptService.ts +0 -183
  115. package/src/services/TemplateService.ts +0 -81
  116. package/src/types/native-exec.d.ts +0 -8
  117. package/src/utils/colors.ts +0 -178
  118. package/src/utils/git-path.ts +0 -6
  119. package/src/utils/glob.test.ts +0 -161
  120. package/src/utils/glob.ts +0 -78
  121. package/src/utils/paths.ts +0 -51
  122. package/src/utils/pr-branch.test.ts +0 -376
  123. package/src/utils/pr-branch.ts +0 -508
  124. package/src/utils/spinner.test.ts +0 -45
  125. package/src/utils/spinner.ts +0 -96
  126. package/src/utils/task-parser.test.ts +0 -172
  127. package/src/utils/task-parser.ts +0 -93
  128. package/templates/AGENCY.md +0 -22
  129. package/templates/AGENTS.md +0 -11
  130. package/templates/CLAUDE.md +0 -3
  131. package/templates/TASK.md +0 -5
  132. package/templates/opencode.json +0 -4
@@ -0,0 +1,146 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Schema } from "@effect/schema"
3
+ import {
4
+ EntityId,
5
+ EpicFrontmatter,
6
+ PhaseFrontmatter,
7
+ TaskFrontmatter,
8
+ WorkbaseConfig,
9
+ } from "./schemas"
10
+
11
+ describe("body-of-work descriptions", () => {
12
+ test("accepts descriptions on epics, tasks, and phases", () => {
13
+ const epic = Schema.decodeUnknownSync(EpicFrontmatter)({
14
+ ticketUrl: "https://example.com/epic",
15
+ description: "Coordinate the rollout.",
16
+ repos: [{ repo: "agency", ref: "main" }],
17
+ tasks: [],
18
+ })
19
+ const singlePhaseTask = Schema.decodeUnknownSync(TaskFrontmatter)({
20
+ ticketUrl: "https://example.com/task",
21
+ description: "Deliver the rollout.",
22
+ repo: "agency",
23
+ branch: "task/rollout",
24
+ base: "main",
25
+ pr: null,
26
+ })
27
+ const multiPhaseTask = Schema.decodeUnknownSync(TaskFrontmatter)({
28
+ ticketUrl: "https://example.com/task",
29
+ description: "Deliver the rollout in phases.",
30
+ phases: [],
31
+ })
32
+ const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
33
+ description: "Introduce the service contract.",
34
+ repo: "agency",
35
+ branch: "task/contract",
36
+ base: "main",
37
+ pr: null,
38
+ })
39
+
40
+ expect(epic.description).toBe("Coordinate the rollout.")
41
+ expect(singlePhaseTask.description).toBe("Deliver the rollout.")
42
+ expect(multiPhaseTask.description).toBe("Deliver the rollout in phases.")
43
+ expect(phase.description).toBe("Introduce the service contract.")
44
+ })
45
+
46
+ test("allows omitted descriptions for existing documents", () => {
47
+ const epic = Schema.decodeUnknownSync(EpicFrontmatter)({
48
+ ticketUrl: "https://example.com/epic",
49
+ repos: [{ repo: "agency", ref: "main" }],
50
+ tasks: [],
51
+ })
52
+
53
+ expect(epic.description).toBeUndefined()
54
+ })
55
+
56
+ test("rejects an empty description when present", () => {
57
+ expect(() =>
58
+ Schema.decodeUnknownSync(PhaseFrontmatter)({
59
+ description: "",
60
+ repo: "agency",
61
+ branch: "task/contract",
62
+ base: "main",
63
+ pr: null,
64
+ }),
65
+ ).toThrow()
66
+ })
67
+ })
68
+
69
+ describe("schema boundaries", () => {
70
+ const rejects = <S extends Schema.Schema.AnyNoContext>(
71
+ schema: S,
72
+ input: unknown,
73
+ ) => {
74
+ expect(() =>
75
+ Schema.decodeUnknownSync(schema, { onExcessProperty: "error" })(input),
76
+ ).toThrow()
77
+ }
78
+
79
+ test("rejects invalid boundary values", () => {
80
+ const cases = [
81
+ {
82
+ name: "unsupported config version",
83
+ schema: WorkbaseConfig,
84
+ input: { version: 1 },
85
+ },
86
+ {
87
+ name: "excess config fields",
88
+ schema: WorkbaseConfig,
89
+ input: { version: 2, legacy: true },
90
+ },
91
+ {
92
+ name: "invalid entity ID",
93
+ schema: EntityId,
94
+ input: "invalid/id",
95
+ },
96
+ {
97
+ name: "empty epic repositories",
98
+ schema: EpicFrontmatter,
99
+ input: {
100
+ ticketUrl: "https://example.com/epic",
101
+ repos: [],
102
+ tasks: [],
103
+ },
104
+ },
105
+ {
106
+ name: "mixed task union",
107
+ schema: TaskFrontmatter,
108
+ input: {
109
+ ticketUrl: "https://example.com/task",
110
+ phases: [],
111
+ repo: "agency",
112
+ branch: "task/mixed",
113
+ base: "main",
114
+ pr: null,
115
+ },
116
+ },
117
+ {
118
+ name: "single-phase task missing pr",
119
+ schema: TaskFrontmatter,
120
+ input: {
121
+ ticketUrl: "https://example.com/task",
122
+ repo: "agency",
123
+ branch: "task/missing-pr",
124
+ base: "main",
125
+ },
126
+ },
127
+ {
128
+ name: "invalid phase PR URL",
129
+ schema: PhaseFrontmatter,
130
+ input: {
131
+ repo: "agency",
132
+ branch: "task/invalid-pr",
133
+ base: "main",
134
+ pr: "https://example.com/pull/1",
135
+ },
136
+ },
137
+ ] as const
138
+
139
+ for (const fixture of cases) {
140
+ expect(
141
+ () => rejects(fixture.schema, fixture.input),
142
+ fixture.name,
143
+ ).not.toThrow()
144
+ }
145
+ })
146
+ })
@@ -0,0 +1,78 @@
1
+ import { Schema } from "@effect/schema"
2
+
3
+ const NonEmptyString = Schema.String.pipe(Schema.minLength(1))
4
+
5
+ const Description = Schema.optional(NonEmptyString)
6
+
7
+ const IdPattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
8
+
9
+ export const EntityId = NonEmptyString.pipe(Schema.pattern(IdPattern))
10
+
11
+ export const RepositoryAlias = NonEmptyString.pipe(Schema.pattern(IdPattern))
12
+
13
+ export const RepositoryReference = Schema.Struct({
14
+ repo: RepositoryAlias,
15
+ ref: NonEmptyString,
16
+ })
17
+
18
+ const Url = NonEmptyString.pipe(Schema.pattern(/^[a-zA-Z][a-zA-Z0-9+.-]*:/))
19
+
20
+ const GitHubPullRequestUrl = NonEmptyString.pipe(
21
+ Schema.pattern(/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/),
22
+ )
23
+
24
+ export const WorkbaseConfig = Schema.Struct({
25
+ version: Schema.Literal(2),
26
+ worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
27
+ })
28
+
29
+ export const Dependency = Schema.Struct({
30
+ id: EntityId,
31
+ dependsOn: Schema.optional(Schema.Array(EntityId)),
32
+ })
33
+
34
+ const ExecutionUnit = {
35
+ repo: RepositoryAlias,
36
+ repos: Schema.optional(Schema.Array(RepositoryReference)),
37
+ branch: NonEmptyString,
38
+ base: NonEmptyString,
39
+ pr: Schema.NullOr(GitHubPullRequestUrl),
40
+ }
41
+
42
+ export const EpicFrontmatter = Schema.Struct({
43
+ ticketUrl: Url,
44
+ description: Description,
45
+ repos: Schema.NonEmptyArray(RepositoryReference),
46
+ tasks: Schema.Array(Dependency),
47
+ })
48
+
49
+ const SinglePhaseTaskFrontmatter = Schema.Struct({
50
+ ticketUrl: Url,
51
+ description: Description,
52
+ epic: Schema.optional(EntityId),
53
+ ...ExecutionUnit,
54
+ })
55
+
56
+ const MultiPhaseTaskFrontmatter = Schema.Struct({
57
+ ticketUrl: Url,
58
+ description: Description,
59
+ epic: Schema.optional(EntityId),
60
+ phases: Schema.Array(Dependency),
61
+ })
62
+
63
+ export const TaskFrontmatter = Schema.Union(
64
+ SinglePhaseTaskFrontmatter,
65
+ MultiPhaseTaskFrontmatter,
66
+ )
67
+
68
+ export const PhaseFrontmatter = Schema.Struct({
69
+ description: Description,
70
+ ...ExecutionUnit,
71
+ })
72
+
73
+ export type WorkbaseConfig = Schema.Schema.Type<typeof WorkbaseConfig>
74
+ export type Dependency = Schema.Schema.Type<typeof Dependency>
75
+ export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
76
+ export type EpicFrontmatter = Schema.Schema.Type<typeof EpicFrontmatter>
77
+ export type TaskFrontmatter = Schema.Schema.Type<typeof TaskFrontmatter>
78
+ export type PhaseFrontmatter = Schema.Schema.Type<typeof PhaseFrontmatter>
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { buildWorkTargetChoices } from "./work-target"
3
+
4
+ describe("work target choices", () => {
5
+ test("builds an ordered hierarchy with standalone tasks", () => {
6
+ const choices = buildWorkTargetChoices(
7
+ [
8
+ {
9
+ id: "delivery",
10
+ path: "/workbase/epics/delivery/EPIC.md",
11
+ data: {
12
+ description: "Ship the release",
13
+ tasks: [{ id: "multi" }, { id: "single" }],
14
+ },
15
+ },
16
+ ],
17
+ [
18
+ {
19
+ id: "multi",
20
+ path: "/workbase/tasks/multi/TASK.md",
21
+ data: {
22
+ phases: [{ id: "build" }, { id: "verify" }],
23
+ },
24
+ },
25
+ {
26
+ id: "single",
27
+ path: "/workbase/tasks/single/TASK.md",
28
+ data: {},
29
+ },
30
+ {
31
+ id: "standalone",
32
+ path: "/workbase/tasks/standalone/TASK.md",
33
+ data: { description: "Independent work" },
34
+ },
35
+ ],
36
+ [
37
+ {
38
+ taskId: "multi",
39
+ id: "verify",
40
+ path: "/workbase/tasks/multi/phases/verify/PHASE.md",
41
+ data: {},
42
+ },
43
+ {
44
+ taskId: "multi",
45
+ id: "build",
46
+ path: "/workbase/tasks/multi/phases/build/PHASE.md",
47
+ data: {},
48
+ },
49
+ {
50
+ taskId: "multi",
51
+ id: "unlisted",
52
+ path: "/workbase/tasks/multi/phases/unlisted/PHASE.md",
53
+ data: {},
54
+ },
55
+ ],
56
+ )
57
+
58
+ expect(choices.map((choice) => choice.label)).toEqual([
59
+ "epic delivery - Ship the release",
60
+ " task multi",
61
+ " phase build",
62
+ " phase verify",
63
+ " phase unlisted",
64
+ " task single",
65
+ "task standalone - Independent work",
66
+ ])
67
+ expect(choices.map((choice) => choice.target.kind)).toEqual([
68
+ "epic",
69
+ "task",
70
+ "phase",
71
+ "phase",
72
+ "phase",
73
+ "task",
74
+ "task",
75
+ ])
76
+ })
77
+ })
@@ -0,0 +1,180 @@
1
+ import { Effect } from "effect"
2
+
3
+ export type WorkTarget =
4
+ | {
5
+ readonly kind: "epic"
6
+ readonly epicId: string
7
+ readonly path: string
8
+ }
9
+ | {
10
+ readonly kind: "task"
11
+ readonly taskId: string
12
+ readonly path: string
13
+ readonly multiPhase: boolean
14
+ }
15
+ | {
16
+ readonly kind: "phase"
17
+ readonly taskId: string
18
+ readonly phaseId: string
19
+ readonly path: string
20
+ }
21
+
22
+ interface EpicRecord {
23
+ readonly id: string
24
+ readonly path: string
25
+ readonly data: {
26
+ readonly description?: string
27
+ readonly tasks: readonly { readonly id: string }[]
28
+ }
29
+ }
30
+
31
+ interface TaskRecord {
32
+ readonly id: string
33
+ readonly path: string
34
+ readonly data:
35
+ | {
36
+ readonly description?: string
37
+ readonly phases: readonly { readonly id: string }[]
38
+ }
39
+ | { readonly description?: string }
40
+ }
41
+
42
+ interface PhaseRecord {
43
+ readonly taskId: string
44
+ readonly id: string
45
+ readonly path: string
46
+ readonly data: { readonly description?: string }
47
+ }
48
+
49
+ export interface WorkTargetChoice {
50
+ readonly label: string
51
+ readonly target: WorkTarget
52
+ }
53
+
54
+ const label = (
55
+ indent: string,
56
+ kind: WorkTarget["kind"],
57
+ id: string,
58
+ description?: string,
59
+ ) =>
60
+ `${indent}${kind} ${id}${description === undefined ? "" : ` - ${description}`}`
61
+
62
+ const taskChoices = (
63
+ task: TaskRecord,
64
+ phaseRecords: readonly PhaseRecord[],
65
+ indent: string,
66
+ ): readonly WorkTargetChoice[] => {
67
+ const multiPhase = "phases" in task.data
68
+ const choices: WorkTargetChoice[] = [
69
+ {
70
+ label: label(indent, "task", task.id, task.data.description),
71
+ target: {
72
+ kind: "task",
73
+ taskId: task.id,
74
+ path: task.path,
75
+ multiPhase,
76
+ },
77
+ },
78
+ ]
79
+ if (!multiPhase) return choices
80
+
81
+ const phases = new Map(phaseRecords.map((phase) => [phase.id, phase]))
82
+ const renderedPhases = new Set<string>()
83
+ for (const phase of task.data.phases) {
84
+ const record = phases.get(phase.id)
85
+ if (!record) continue
86
+ renderedPhases.add(record.id)
87
+ choices.push({
88
+ label: label(`${indent} `, "phase", record.id, record.data.description),
89
+ target: {
90
+ kind: "phase",
91
+ taskId: task.id,
92
+ phaseId: record.id,
93
+ path: record.path,
94
+ },
95
+ })
96
+ }
97
+ for (const record of phaseRecords) {
98
+ if (renderedPhases.has(record.id)) continue
99
+ choices.push({
100
+ label: label(`${indent} `, "phase", record.id, record.data.description),
101
+ target: {
102
+ kind: "phase",
103
+ taskId: task.id,
104
+ phaseId: record.id,
105
+ path: record.path,
106
+ },
107
+ })
108
+ }
109
+ return choices
110
+ }
111
+
112
+ export const buildWorkTargetChoices = (
113
+ epicRecords: readonly EpicRecord[],
114
+ taskRecords: readonly TaskRecord[],
115
+ phaseRecords: readonly PhaseRecord[],
116
+ ): readonly WorkTargetChoice[] => {
117
+ const choices: WorkTargetChoice[] = []
118
+ const tasks = new Map(taskRecords.map((task) => [task.id, task]))
119
+ const phases = new Map<string, PhaseRecord[]>()
120
+ for (const phase of phaseRecords) {
121
+ const records = phases.get(phase.taskId) ?? []
122
+ records.push(phase)
123
+ phases.set(phase.taskId, records)
124
+ }
125
+ const nestedTasks = new Set<string>()
126
+
127
+ for (const epic of epicRecords) {
128
+ choices.push({
129
+ label: label("", "epic", epic.id, epic.data.description),
130
+ target: { kind: "epic", epicId: epic.id, path: epic.path },
131
+ })
132
+ for (const child of epic.data.tasks) {
133
+ const task = tasks.get(child.id)
134
+ if (!task || nestedTasks.has(task.id)) continue
135
+ nestedTasks.add(task.id)
136
+ choices.push(...taskChoices(task, phases.get(task.id) ?? [], " "))
137
+ }
138
+ }
139
+
140
+ for (const task of taskRecords) {
141
+ if (nestedTasks.has(task.id)) continue
142
+ choices.push(...taskChoices(task, phases.get(task.id) ?? [], ""))
143
+ }
144
+
145
+ return choices
146
+ }
147
+
148
+ export type PickWorkTarget = (
149
+ choices: readonly WorkTargetChoice[],
150
+ ) => Effect.Effect<WorkTarget | null, Error>
151
+
152
+ const parseWorkTargetSelection = (
153
+ output: string,
154
+ choices: readonly WorkTargetChoice[],
155
+ ) => {
156
+ const index = Number.parseInt(output.split("\t", 1)[0] ?? "", 10)
157
+ return choices[index]?.target ?? null
158
+ }
159
+
160
+ export const pickWorkTarget: PickWorkTarget = (choices) =>
161
+ Effect.tryPromise({
162
+ try: async () => {
163
+ const input = choices
164
+ .map((choice, index) => `${index}\t${choice.label}`)
165
+ .join("\n")
166
+ const process = Bun.spawn(
167
+ ["fzf", "--delimiter=\t", "--with-nth=2..", "--prompt=Work on> "],
168
+ { stdin: new Blob([input]), stdout: "pipe", stderr: "inherit" },
169
+ )
170
+ const [exitCode, output] = await Promise.all([
171
+ process.exited,
172
+ new Response(process.stdout).text(),
173
+ ])
174
+ if (exitCode === 1 || exitCode === 130) return null
175
+ if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
176
+ return parseWorkTargetSelection(output, choices)
177
+ },
178
+ catch: (cause) =>
179
+ new Error("Failed to select a work target with fzf", { cause }),
180
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ expandWorktreeCreateCommand,
4
+ worktreeCommandEnvironment,
5
+ } from "./worktree-command"
6
+
7
+ const variables = {
8
+ repo: "/work/repos/app",
9
+ worktree: "/work/tasks/example/code/app",
10
+ branch: "task/example",
11
+ base: "main",
12
+ }
13
+
14
+ describe("worktree command templates", () => {
15
+ test("expands argv placeholders without shell interpolation", () => {
16
+ expect(
17
+ expandWorktreeCreateCommand(
18
+ [
19
+ "tool",
20
+ "--repo={repo}",
21
+ "--worktree",
22
+ "{worktree}",
23
+ "{branch}",
24
+ "{base}",
25
+ ],
26
+ variables,
27
+ ),
28
+ ).toEqual([
29
+ "tool",
30
+ "--repo=/work/repos/app",
31
+ "--worktree",
32
+ "/work/tasks/example/code/app",
33
+ "task/example",
34
+ "main",
35
+ ])
36
+ })
37
+
38
+ test("requires repo and worktree placeholders", () => {
39
+ expect(() =>
40
+ expandWorktreeCreateCommand(["tool", "{repo}"], variables),
41
+ ).toThrow("{worktree}")
42
+ })
43
+
44
+ test("rejects unknown placeholders", () => {
45
+ expect(() =>
46
+ expandWorktreeCreateCommand(
47
+ ["tool", "{repo}", "{worktree}", "{unknown}"],
48
+ variables,
49
+ ),
50
+ ).toThrow("{unknown}")
51
+ })
52
+
53
+ test("provides equivalent environment variables", () => {
54
+ expect(worktreeCommandEnvironment(variables)).toEqual({
55
+ AGENCY_REPO: variables.repo,
56
+ AGENCY_WORKTREE: variables.worktree,
57
+ AGENCY_BRANCH: variables.branch,
58
+ AGENCY_BASE: variables.base,
59
+ })
60
+ })
61
+ })
@@ -0,0 +1,52 @@
1
+ interface WorktreeCommandVariables {
2
+ readonly repo: string
3
+ readonly worktree: string
4
+ readonly branch: string
5
+ readonly base: string
6
+ }
7
+
8
+ const REQUIRED_PLACEHOLDERS = ["repo", "worktree"] as const
9
+ const PLACEHOLDERS = new Set(["repo", "worktree", "branch", "base"])
10
+
11
+ export const validateWorktreeCreateCommand = (command: readonly string[]) => {
12
+ const template = command.join("\u0000")
13
+ for (const placeholder of REQUIRED_PLACEHOLDERS) {
14
+ if (!template.includes(`{${placeholder}}`)) {
15
+ throw new Error(
16
+ `worktreeCreateCommand must include the {${placeholder}} placeholder`,
17
+ )
18
+ }
19
+ }
20
+ for (const argument of command) {
21
+ for (const match of argument.matchAll(/\{([^{}]+)\}/g)) {
22
+ const placeholder = match[1]!
23
+ if (!PLACEHOLDERS.has(placeholder)) {
24
+ throw new Error(
25
+ `Unknown worktreeCreateCommand placeholder: {${placeholder}}`,
26
+ )
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ export const expandWorktreeCreateCommand = (
33
+ command: readonly string[],
34
+ variables: WorktreeCommandVariables,
35
+ ): string[] => {
36
+ validateWorktreeCreateCommand(command)
37
+
38
+ return command.map((argument) =>
39
+ argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {
40
+ return variables[placeholder as keyof WorktreeCommandVariables] ?? match
41
+ }),
42
+ )
43
+ }
44
+
45
+ export const worktreeCommandEnvironment = (
46
+ variables: WorktreeCommandVariables,
47
+ ): Record<string, string> => ({
48
+ AGENCY_REPO: variables.repo,
49
+ AGENCY_WORKTREE: variables.worktree,
50
+ AGENCY_BRANCH: variables.branch,
51
+ AGENCY_BASE: variables.base,
52
+ })