@markjaquith/agency 2.14.0 → 2.16.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/src/test-utils.ts CHANGED
@@ -16,6 +16,7 @@ import { IntegrationService } from "./services/IntegrationService"
16
16
  import { ContextService } from "./services/ContextService"
17
17
  import { GraphService } from "./services/GraphService"
18
18
  import { ClaimService } from "./services/ClaimService"
19
+ import { SyncService } from "./services/SyncService"
19
20
 
20
21
  export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
21
22
 
@@ -36,6 +37,7 @@ const TestLayer = Layer.mergeAll(
36
37
  ContextService.Default,
37
38
  GraphService.Default,
38
39
  ClaimService.Default,
40
+ SyncService.Default,
39
41
  )
40
42
 
41
43
  export async function runTestEffect<A, E>(
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { choose, type ChooserIO } from "./chooser"
4
+
5
+ const choices = [
6
+ { key: "first-key", label: "\x1b[32mFirst\x1b[0m", value: 1 },
7
+ { key: "second key", label: "Second", value: 2 },
8
+ ]
9
+
10
+ const createIO = (
11
+ overrides: Partial<ChooserIO> = {},
12
+ ): ChooserIO & { readonly writes: string[]; readonly inputs: string[] } => {
13
+ const writes: string[] = []
14
+ const inputs: string[] = []
15
+ return {
16
+ inputIsTTY: true,
17
+ outputIsTTY: true,
18
+ color: false,
19
+ write: (message) => writes.push(message),
20
+ question: async () => "1",
21
+ run: async (_command, input) => {
22
+ inputs.push(input)
23
+ return { exitCode: 0, stdout: "first-key\n" }
24
+ },
25
+ ...overrides,
26
+ writes,
27
+ inputs,
28
+ }
29
+ }
30
+
31
+ describe("chooser", () => {
32
+ test("offers a plain-text numbered chooser on a TTY", async () => {
33
+ const io = createIO({ question: async () => "2" })
34
+
35
+ const result = await Effect.runPromise(
36
+ choose("Pick one", choices, undefined, io),
37
+ )
38
+
39
+ expect(result).toBe(2)
40
+ expect(io.writes.join("")).toBe("Pick one\n 1. First\n 2. Second\n")
41
+ })
42
+
43
+ test("preserves colors when enabled", async () => {
44
+ const io = createIO({ color: true })
45
+
46
+ await Effect.runPromise(choose("Pick one", choices, undefined, io))
47
+
48
+ expect(io.writes.join("")).toContain("\x1b[32mFirst\x1b[0m")
49
+ })
50
+
51
+ test("passes generic records to an external argv command", async () => {
52
+ const io = createIO()
53
+
54
+ const result = await Effect.runPromise(
55
+ choose("Pick one", choices, ["custom-chooser", "--flag"], io),
56
+ )
57
+
58
+ expect(result).toBe(1)
59
+ expect(io.inputs).toEqual(["first-key\tFirst\nsecond key\tSecond\n"])
60
+ })
61
+
62
+ test("accepts a selected record from fzf or gum", async () => {
63
+ const io = createIO({
64
+ run: async () => ({ exitCode: 0, stdout: "second key\tSecond\n" }),
65
+ })
66
+
67
+ expect(
68
+ await Effect.runPromise(choose("Pick", choices, ["gum", "filter"], io)),
69
+ ).toBe(2)
70
+ })
71
+
72
+ test("treats native and external cancellation as no selection", async () => {
73
+ const native = createIO({ question: async () => "q" })
74
+ const external = createIO({
75
+ run: async () => ({ exitCode: 130, stdout: "" }),
76
+ })
77
+
78
+ expect(
79
+ await Effect.runPromise(choose("Pick", choices, undefined, native)),
80
+ ).toBeNull()
81
+ expect(
82
+ await Effect.runPromise(choose("Pick", choices, ["chooser"], external)),
83
+ ).toBeNull()
84
+ })
85
+
86
+ test("uses one typed error for unavailable input and invalid keys", async () => {
87
+ const nonTTY = createIO({ inputIsTTY: false })
88
+ const unknownKey = createIO({
89
+ run: async () => ({ exitCode: 0, stdout: "missing\n" }),
90
+ })
91
+
92
+ const unavailable = await Effect.runPromise(
93
+ Effect.flip(choose("Pick", choices, undefined, nonTTY)),
94
+ )
95
+ const invalid = await Effect.runPromise(
96
+ Effect.flip(choose("Pick", choices, ["chooser"], unknownKey)),
97
+ )
98
+
99
+ expect(unavailable).toMatchObject({
100
+ name: "ChooserError",
101
+ reason: "input-unavailable",
102
+ })
103
+ expect(invalid).toMatchObject({
104
+ name: "ChooserError",
105
+ reason: "invalid-selection",
106
+ })
107
+ })
108
+ })
@@ -0,0 +1,222 @@
1
+ import { Effect } from "effect"
2
+ import { createInterface } from "node:readline/promises"
3
+
4
+ export interface Choice<T> {
5
+ readonly key: string
6
+ readonly label: string
7
+ readonly plainLabel?: string
8
+ readonly value: T
9
+ }
10
+
11
+ export type ChooserErrorReason =
12
+ | "invalid-choices"
13
+ | "input-unavailable"
14
+ | "invalid-selection"
15
+ | "command-failed"
16
+
17
+ export class ChooserError extends Error {
18
+ override readonly name = "ChooserError"
19
+
20
+ constructor(
21
+ readonly reason: ChooserErrorReason,
22
+ message: string,
23
+ options?: ErrorOptions,
24
+ ) {
25
+ super(message, options)
26
+ }
27
+ }
28
+
29
+ interface ExternalResult {
30
+ readonly exitCode: number
31
+ readonly stdout: string
32
+ }
33
+
34
+ export interface ChooserIO {
35
+ readonly inputIsTTY: boolean
36
+ readonly outputIsTTY: boolean
37
+ readonly color: boolean
38
+ readonly write: (message: string) => void
39
+ readonly question: (prompt: string) => Promise<string>
40
+ readonly run: (
41
+ command: readonly string[],
42
+ input: string,
43
+ ) => Promise<ExternalResult>
44
+ }
45
+
46
+ const stripAnsi = (value: string) =>
47
+ value.replace(
48
+ /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g,
49
+ "",
50
+ )
51
+
52
+ const defaultIO = (): ChooserIO => ({
53
+ inputIsTTY: Boolean(process.stdin.isTTY),
54
+ outputIsTTY: Boolean(process.stderr.isTTY),
55
+ color:
56
+ Boolean(process.stderr.isTTY) &&
57
+ process.env.NO_COLOR === undefined &&
58
+ process.env.TERM !== "dumb",
59
+ write: (message) => process.stderr.write(message),
60
+ question: async (prompt) => {
61
+ const input = createInterface({
62
+ input: process.stdin,
63
+ output: process.stderr,
64
+ })
65
+ try {
66
+ return await input.question(prompt)
67
+ } finally {
68
+ input.close()
69
+ }
70
+ },
71
+ run: async (command, input) => {
72
+ const child = Bun.spawn([...command], {
73
+ stdin: new Blob([input]),
74
+ stdout: "pipe",
75
+ stderr: "inherit",
76
+ })
77
+ const [exitCode, stdout] = await Promise.all([
78
+ child.exited,
79
+ new Response(child.stdout).text(),
80
+ ])
81
+ return { exitCode, stdout }
82
+ },
83
+ })
84
+
85
+ const displayLabel = (choice: Choice<unknown>, color: boolean) => {
86
+ const normalized = (
87
+ color ? choice.label : (choice.plainLabel ?? choice.label)
88
+ ).replace(/[\r\n]+/g, " ")
89
+ return color ? normalized : stripAnsi(normalized)
90
+ }
91
+
92
+ const validateChoices = <T>(choices: readonly Choice<T>[]) => {
93
+ if (choices.length === 0) {
94
+ throw new ChooserError(
95
+ "invalid-choices",
96
+ "Cannot choose from an empty list",
97
+ )
98
+ }
99
+ const keys = new Set<string>()
100
+ for (const choice of choices) {
101
+ if (!choice.key || /[\t\r\n]/.test(choice.key)) {
102
+ throw new ChooserError(
103
+ "invalid-choices",
104
+ "Chooser keys must be non-empty and cannot contain tabs or newlines",
105
+ )
106
+ }
107
+ if (keys.has(choice.key)) {
108
+ throw new ChooserError(
109
+ "invalid-choices",
110
+ `Duplicate chooser key: ${choice.key}`,
111
+ )
112
+ }
113
+ keys.add(choice.key)
114
+ }
115
+ }
116
+
117
+ const selectedChoice = <T>(key: string, choices: readonly Choice<T>[]) => {
118
+ const selected = choices.find((choice) => choice.key === key)
119
+ if (!selected) {
120
+ throw new ChooserError(
121
+ "invalid-selection",
122
+ `Chooser returned an unknown key: ${key}`,
123
+ )
124
+ }
125
+ return selected.value
126
+ }
127
+
128
+ const externalChoice = async <T>(
129
+ choices: readonly Choice<T>[],
130
+ command: readonly string[],
131
+ io: ChooserIO,
132
+ ) => {
133
+ let result: ExternalResult
134
+ try {
135
+ const records = `${choices
136
+ .map((choice) => `${choice.key}\t${displayLabel(choice, io.color)}`)
137
+ .join("\n")}\n`
138
+ result = await io.run(command, records)
139
+ } catch (cause) {
140
+ throw new ChooserError(
141
+ "command-failed",
142
+ `Failed to run chooser command: ${command.join(" ")}`,
143
+ { cause },
144
+ )
145
+ }
146
+ if (result.exitCode === 1 || result.exitCode === 130) return null
147
+ if (result.exitCode !== 0) {
148
+ throw new ChooserError(
149
+ "command-failed",
150
+ `Chooser command exited with code ${result.exitCode}`,
151
+ )
152
+ }
153
+ const output = result.stdout.replace(/\r?\n$/, "")
154
+ if (!output) return null
155
+ if (/[\r\n]/.test(output)) {
156
+ throw new ChooserError(
157
+ "invalid-selection",
158
+ "Chooser command returned more than one key",
159
+ )
160
+ }
161
+ const key = output.split("\t", 1)[0] ?? ""
162
+ return selectedChoice(key, choices)
163
+ }
164
+
165
+ const nativeChoice = async <T>(
166
+ choices: readonly Choice<T>[],
167
+ prompt: string,
168
+ io: ChooserIO,
169
+ ) => {
170
+ if (!io.inputIsTTY || !io.outputIsTTY) {
171
+ throw new ChooserError(
172
+ "input-unavailable",
173
+ "Interactive selection requires a terminal; provide an explicit value or use --no-input",
174
+ )
175
+ }
176
+ io.write(`${prompt}\n`)
177
+ for (const [index, choice] of choices.entries()) {
178
+ io.write(` ${index + 1}. ${displayLabel(choice, io.color)}\n`)
179
+ }
180
+ let answer: string
181
+ try {
182
+ answer = (
183
+ await io.question(`Select [1-${choices.length}] (q to cancel): `)
184
+ ).trim()
185
+ } catch (cause) {
186
+ if (cause instanceof Error && cause.name === "AbortError") return null
187
+ throw new ChooserError("input-unavailable", "Failed to read selection", {
188
+ cause,
189
+ })
190
+ }
191
+ if (!answer || answer.toLowerCase() === "q") return null
192
+ if (!/^\d+$/.test(answer)) {
193
+ throw new ChooserError("invalid-selection", `Invalid selection: ${answer}`)
194
+ }
195
+ const selected = choices[Number.parseInt(answer, 10) - 1]
196
+ if (!selected) {
197
+ throw new ChooserError(
198
+ "invalid-selection",
199
+ `Selection must be between 1 and ${choices.length}`,
200
+ )
201
+ }
202
+ return selected.value
203
+ }
204
+
205
+ export const choose = <T>(
206
+ prompt: string,
207
+ choices: readonly Choice<T>[],
208
+ command?: readonly string[],
209
+ io: ChooserIO = defaultIO(),
210
+ ): Effect.Effect<T | null, ChooserError> =>
211
+ Effect.tryPromise({
212
+ try: async () => {
213
+ validateChoices(choices)
214
+ return command
215
+ ? await externalChoice(choices, command, io)
216
+ : await nativeChoice(choices, prompt, io)
217
+ },
218
+ catch: (cause) =>
219
+ cause instanceof ChooserError
220
+ ? cause
221
+ : new ChooserError("input-unavailable", "Selection failed", { cause }),
222
+ })
@@ -284,4 +284,16 @@ describe("schema boundaries", () => {
284
284
  ).not.toThrow()
285
285
  }
286
286
  })
287
+
288
+ test("accepts a configured chooser argv", () => {
289
+ expect(
290
+ Schema.decodeUnknownSync(WorkbaseConfig)({
291
+ version: 2,
292
+ chooserCommand: ["fzf", "--accept-nth=1"],
293
+ }),
294
+ ).toEqual({
295
+ version: 2,
296
+ chooserCommand: ["fzf", "--accept-nth=1"],
297
+ })
298
+ })
287
299
  })
@@ -50,6 +50,7 @@ const GitHubPullRequestUrl = NonEmptyString.pipe(
50
50
 
51
51
  export const WorkbaseConfig = Schema.Struct({
52
52
  version: Schema.Literal(2),
53
+ chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
53
54
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
54
55
  })
55
56
 
@@ -80,5 +80,15 @@ describe("work target choices", () => {
80
80
  "task",
81
81
  "task",
82
82
  ])
83
+ expect(choices.map((choice) => choice.plainLabel)).toEqual([
84
+ "epic delivery - Ship the release",
85
+ " task multi",
86
+ " [working] phase build",
87
+ " [done] phase verify",
88
+ " [dropped] phase unlisted",
89
+ " [done] task single",
90
+ "[open] task standalone - Independent work",
91
+ "[delegated] task delegated",
92
+ ])
83
93
  })
84
94
  })
@@ -1,5 +1,6 @@
1
1
  import { Effect } from "effect"
2
2
  import type { WorkStatus } from "./schemas"
3
+ import { choose } from "../utils/chooser"
3
4
 
4
5
  export type WorkTarget =
5
6
  | {
@@ -52,6 +53,7 @@ interface PhaseRecord {
52
53
 
53
54
  export interface WorkTargetChoice {
54
55
  readonly label: string
56
+ readonly plainLabel: string
55
57
  readonly target: WorkTarget
56
58
  }
57
59
 
@@ -78,6 +80,15 @@ const label = (
78
80
  }[kind]
79
81
  } ${id}${description === undefined ? "" : `\x1b[2m - ${description}\x1b[0m`}`
80
82
 
83
+ const plainLabel = (
84
+ indent: string,
85
+ kind: WorkTarget["kind"],
86
+ id: string,
87
+ description?: string,
88
+ status?: WorkStatus,
89
+ ) =>
90
+ `${indent}${status === undefined ? "" : `[${status}] `}${kind} ${id}${description === undefined ? "" : ` - ${description}`}`
91
+
81
92
  const taskChoices = (
82
93
  task: TaskRecord,
83
94
  phaseRecords: readonly PhaseRecord[],
@@ -93,6 +104,13 @@ const taskChoices = (
93
104
  task.data.description,
94
105
  multiPhase ? undefined : (task.data.status ?? "open"),
95
106
  ),
107
+ plainLabel: plainLabel(
108
+ indent,
109
+ "task",
110
+ task.id,
111
+ task.data.description,
112
+ multiPhase ? undefined : (task.data.status ?? "open"),
113
+ ),
96
114
  target: {
97
115
  kind: "task",
98
116
  taskId: task.id,
@@ -117,6 +135,13 @@ const taskChoices = (
117
135
  record.data.description,
118
136
  record.data.status ?? "open",
119
137
  ),
138
+ plainLabel: plainLabel(
139
+ `${indent} `,
140
+ "phase",
141
+ record.id,
142
+ record.data.description,
143
+ record.data.status ?? "open",
144
+ ),
120
145
  target: {
121
146
  kind: "phase",
122
147
  taskId: task.id,
@@ -135,6 +160,13 @@ const taskChoices = (
135
160
  record.data.description,
136
161
  record.data.status ?? "open",
137
162
  ),
163
+ plainLabel: plainLabel(
164
+ `${indent} `,
165
+ "phase",
166
+ record.id,
167
+ record.data.description,
168
+ record.data.status ?? "open",
169
+ ),
138
170
  target: {
139
171
  kind: "phase",
140
172
  taskId: task.id,
@@ -164,6 +196,7 @@ export const buildWorkTargetChoices = (
164
196
  for (const epic of epicRecords) {
165
197
  choices.push({
166
198
  label: label("", "epic", epic.id, epic.data.description),
199
+ plainLabel: plainLabel("", "epic", epic.id, epic.data.description),
167
200
  target: { kind: "epic", epicId: epic.id, path: epic.path },
168
201
  })
169
202
  for (const child of epic.data.tasks) {
@@ -184,40 +217,17 @@ export const buildWorkTargetChoices = (
184
217
 
185
218
  export type PickWorkTarget = (
186
219
  choices: readonly WorkTargetChoice[],
220
+ command?: readonly string[],
187
221
  ) => Effect.Effect<WorkTarget | null, Error>
188
222
 
189
- const parseWorkTargetSelection = (
190
- output: string,
191
- choices: readonly WorkTargetChoice[],
192
- ) => {
193
- const index = Number.parseInt(output.split("\t", 1)[0] ?? "", 10)
194
- return choices[index]?.target ?? null
195
- }
196
-
197
- export const pickWorkTarget: PickWorkTarget = (choices) =>
198
- Effect.tryPromise({
199
- try: async () => {
200
- const input = choices
201
- .map((choice, index) => `${index}\t${choice.label}`)
202
- .join("\n")
203
- const process = Bun.spawn(
204
- [
205
- "fzf",
206
- "--ansi",
207
- "--delimiter=\t",
208
- "--with-nth=2..",
209
- "--prompt=Work on> ",
210
- ],
211
- { stdin: new Blob([input]), stdout: "pipe", stderr: "inherit" },
212
- )
213
- const [exitCode, output] = await Promise.all([
214
- process.exited,
215
- new Response(process.stdout).text(),
216
- ])
217
- if (exitCode === 1 || exitCode === 130) return null
218
- if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
219
- return parseWorkTargetSelection(output, choices)
220
- },
221
- catch: (cause) =>
222
- new Error("Failed to select a work target with fzf", { cause }),
223
- })
223
+ export const pickWorkTarget: PickWorkTarget = (choices, command) =>
224
+ choose(
225
+ "Work on",
226
+ choices.map((choice, index) => ({
227
+ key: String(index),
228
+ label: choice.label,
229
+ plainLabel: choice.plainLabel,
230
+ value: choice.target,
231
+ })),
232
+ command,
233
+ )
@@ -1,43 +1,30 @@
1
1
  import { Effect } from "effect"
2
2
  import { resolve } from "node:path"
3
- import { FileSystemService } from "../services/FileSystemService"
4
3
  import { WorkbaseService } from "../services/WorkbaseService"
4
+ import { choose } from "../utils/chooser"
5
5
 
6
6
  export type PickWorkbase = (
7
7
  workbases: readonly string[],
8
+ command?: readonly string[],
8
9
  ) => Effect.Effect<string | null, Error>
9
10
 
10
- export const pickWorkbase: PickWorkbase = (workbases) =>
11
- Effect.tryPromise({
12
- try: async () => {
13
- const input = workbases
14
- .map((workbase, index) => `${index}\t${workbase}`)
15
- .join("\n")
16
- const process = Bun.spawn(
17
- ["fzf", "--delimiter=\t", "--with-nth=2..", "--prompt=Workbase> "],
18
- { stdin: new Blob([input]), stdout: "pipe", stderr: "inherit" },
19
- )
20
- const [exitCode, output] = await Promise.all([
21
- process.exited,
22
- new Response(process.stdout).text(),
23
- ])
24
- if (exitCode === 1 || exitCode === 130) return null
25
- if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
26
- const index = Number.parseInt(output.split("\t", 1)[0] ?? "", 10)
27
- return workbases[index] ?? null
28
- },
29
- catch: (cause) =>
30
- new Error("Failed to select a workbase with fzf", { cause }),
31
- })
11
+ export const pickWorkbase: PickWorkbase = (workbases, command) =>
12
+ choose(
13
+ "Workbase",
14
+ workbases.map((workbase, index) => ({
15
+ key: String(index),
16
+ label: workbase,
17
+ value: workbase,
18
+ })),
19
+ command,
20
+ )
32
21
 
33
22
  export const resolveWorkbase = (
34
23
  startPath: string,
35
- log: (message: string) => void,
36
24
  pick: PickWorkbase = pickWorkbase,
37
25
  inputAllowed = true,
38
26
  ) =>
39
27
  Effect.gen(function* () {
40
- const fs = yield* FileSystemService
41
28
  const workbase = yield* WorkbaseService
42
29
 
43
30
  return yield* workbase.discover(startPath).pipe(
@@ -59,18 +46,6 @@ export const resolveWorkbase = (
59
46
  )
60
47
  }
61
48
 
62
- const fzf = yield* fs.runCommand(["which", "fzf"], {
63
- captureOutput: true,
64
- })
65
- if (fzf.exitCode !== 0) {
66
- for (const path of registered) log(path)
67
- return yield* Effect.fail(
68
- new Error(
69
- "fzf is required to select a workbase; install fzf or run Agency from a registered workbase",
70
- ),
71
- )
72
- }
73
-
74
49
  const selected = yield* pick(registered)
75
50
  return selected ? yield* workbase.discover(selected) : null
76
51
  }),