@markjaquith/agency 2.14.0 → 2.15.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/README.md CHANGED
@@ -11,7 +11,6 @@ or write.
11
11
  - Git
12
12
  - [GitHub CLI](https://cli.github.com/) for `agency pr create`
13
13
  - OpenCode or Claude Code for `agency work`
14
- - [fzf](https://github.com/junegunn/fzf) for interactive work target selection
15
14
 
16
15
  ## Installation
17
16
 
@@ -146,6 +145,29 @@ The configured command applies only to the writable checkout. Supplemental
146
145
  read-only repositories remain detached Git worktrees at their declared refs so
147
146
  they do not acquire writable branches.
148
147
 
148
+ ### Custom Chooser Command
149
+
150
+ Interactive selection uses a native numbered chooser by default. To use an
151
+ external chooser, configure an argv command in `agency.json`:
152
+
153
+ ```json
154
+ {
155
+ "version": 2,
156
+ "chooserCommand": ["fzf", "--ansi", "--delimiter=\\t", "--with-nth=2.."]
157
+ }
158
+ ```
159
+
160
+ Agency writes one `key<TAB>label` record per choice to the command's stdin. The
161
+ command must write the selected opaque key or selected record to stdout; commands
162
+ such as `["gum", "filter"]` therefore work without wrappers. Exit codes 1 and
163
+ 130, empty stdout, native `q`, and an empty native response cancel selection.
164
+ Other nonzero exits, unknown keys, and invalid native numbers are errors.
165
+
166
+ Selectors are opened only when stdin and stderr are terminals and neither
167
+ `--no-input` nor JSON output is active. Labels use color only when stderr is a
168
+ terminal, `TERM` is not `dumb`, and `NO_COLOR` is unset; otherwise selectors use
169
+ plain labels without ANSI styling or icon-font dependencies.
170
+
149
171
  ## Frontmatter
150
172
 
151
173
  ### Epic
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -6,6 +6,8 @@ import { EpicService } from "../services/EpicService"
6
6
  import { RepositoryService } from "../services/RepositoryService"
7
7
  import { createLoggers } from "../utils/effect"
8
8
  import { parseRepositoryReferences } from "../workbase/repository-reference"
9
+ import { WorkbaseService } from "../services/WorkbaseService"
10
+ import { choose } from "../utils/chooser"
9
11
 
10
12
  interface TaskOptions extends BaseCommandOptions {
11
13
  readonly subcommand?: string
@@ -29,7 +31,9 @@ export interface TaskInteraction {
29
31
  ) => Effect.Effect<string | null, Error>
30
32
  }
31
33
 
32
- const defaultInteraction: TaskInteraction = {
34
+ const defaultInteraction = (
35
+ chooserCommand?: readonly string[],
36
+ ): TaskInteraction => ({
33
37
  text: (prompt) =>
34
38
  Effect.tryPromise({
35
39
  try: async () => {
@@ -46,37 +50,23 @@ const defaultInteraction: TaskInteraction = {
46
50
  catch: (cause) => new Error("Failed to read task input", { cause }),
47
51
  }),
48
52
  select: (prompt, choices) =>
49
- Effect.tryPromise({
50
- try: async () => {
51
- const process = Bun.spawn(
52
- ["fzf", `--prompt=${prompt}> `, "--height=~40%", "--reverse"],
53
- {
54
- stdin: new Blob([choices.join("\n")]),
55
- stdout: "pipe",
56
- stderr: "inherit",
57
- },
58
- )
59
- const [exitCode, output] = await Promise.all([
60
- process.exited,
61
- new Response(process.stdout).text(),
62
- ])
63
- if (exitCode === 1 || exitCode === 130) return null
64
- if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
65
- return output.trim() || null
66
- },
67
- catch: (cause) =>
68
- new Error("Failed to select task input with fzf", { cause }),
69
- }),
70
- }
53
+ choose(
54
+ prompt,
55
+ choices.map((choice, index) => ({
56
+ key: String(index),
57
+ label: choice,
58
+ value: choice,
59
+ })),
60
+ chooserCommand,
61
+ ),
62
+ })
71
63
 
72
- export const task = (
73
- options: TaskOptions,
74
- interaction: TaskInteraction = defaultInteraction,
75
- ) =>
64
+ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
76
65
  Effect.gen(function* () {
77
66
  const tasks = yield* TaskService
78
67
  const epics = yield* EpicService
79
68
  const repositories = yield* RepositoryService
69
+ const workbase = yield* WorkbaseService
80
70
  const { log } = createLoggers(options)
81
71
  const cwd = options.cwd ?? process.cwd()
82
72
 
@@ -89,8 +79,13 @@ export const task = (
89
79
  ),
90
80
  )
91
81
  }
82
+ const activeInteraction =
83
+ interaction ??
84
+ defaultInteraction(
85
+ (yield* workbase.loadConfig(cwd)).config.chooserCommand,
86
+ )
92
87
  const id =
93
- options.args[0] ?? (yield* interaction.text("Task ID: ")).trim()
88
+ options.args[0] ?? (yield* activeInteraction.text("Task ID: ")).trim()
94
89
  if (!id) {
95
90
  return yield* Effect.fail(new Error("Task ID is required"))
96
91
  }
@@ -103,18 +98,20 @@ export const task = (
103
98
 
104
99
  if (options.ticketUrl === undefined) {
105
100
  ticketUrl =
106
- (yield* interaction.text("Ticket URL (optional): ")).trim() || null
101
+ (yield* activeInteraction.text("Ticket URL (optional): ")).trim() ||
102
+ null
107
103
  }
108
104
  if (options.description === undefined) {
109
105
  description =
110
- (yield* interaction.text("Description (optional): ")).trim() ||
111
- undefined
106
+ (yield* activeInteraction.text(
107
+ "Description (optional): ",
108
+ )).trim() || undefined
112
109
  }
113
110
  if (options.epic === undefined) {
114
111
  const epicRecords = yield* epics.list(cwd)
115
112
  if (epicRecords.length > 0) {
116
113
  const none = "(none)"
117
- const selected = yield* interaction.select("Parent epic", [
114
+ const selected = yield* activeInteraction.select("Parent epic", [
118
115
  none,
119
116
  ...epicRecords.map((record) => record.id),
120
117
  ])
@@ -125,7 +122,7 @@ export const task = (
125
122
  }
126
123
  }
127
124
  if (options.multiPhase === undefined) {
128
- const selected = yield* interaction.select("Task type", [
125
+ const selected = yield* activeInteraction.select("Task type", [
129
126
  "single-phase",
130
127
  "multi-phase",
131
128
  ])
@@ -144,7 +141,7 @@ export const task = (
144
141
  ),
145
142
  )
146
143
  }
147
- const selected = yield* interaction.select(
144
+ const selected = yield* activeInteraction.select(
148
145
  "Writable repository",
149
146
  records.map((record) => record.alias),
150
147
  )
@@ -32,12 +32,7 @@ export const validate = (
32
32
  const startPath = options.path ?? options.cwd ?? process.cwd()
33
33
  const root = options.path
34
34
  ? yield* workbase.discover(startPath)
35
- : yield* resolveWorkbase(
36
- startPath,
37
- log,
38
- pick,
39
- options.inputAllowed ?? true,
40
- )
35
+ : yield* resolveWorkbase(startPath, pick, options.inputAllowed ?? true)
41
36
  if (!root) return
42
37
  const report = yield* workbase.validate(root)
43
38
 
@@ -46,7 +46,8 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
46
46
  interface HarnessOptions {
47
47
  readonly workspace?: ExecutionWorkspace
48
48
  readonly materializeError?: Error
49
- readonly available?: Partial<Record<"opencode" | "claude" | "fzf", boolean>>
49
+ readonly available?: Partial<Record<"opencode" | "claude", boolean>>
50
+ readonly chooserCommand?: readonly string[]
50
51
  readonly multiPhaseTasks?: readonly string[]
51
52
  readonly epicRecords?: readonly any[]
52
53
  readonly taskRecords?: readonly any[]
@@ -93,6 +94,14 @@ const createHarness = (options: HarnessOptions = {}) => {
93
94
  })
94
95
  : Effect.succeed("/workbase"),
95
96
  listRegistered: () => Effect.succeed(options.registeredWorkbases ?? []),
97
+ loadConfig: () =>
98
+ Effect.succeed({
99
+ root: "/workbase",
100
+ config: {
101
+ version: 2 as const,
102
+ chooserCommand: options.chooserCommand,
103
+ },
104
+ }),
96
105
  }
97
106
  const epics = {
98
107
  show: (id: string) =>
@@ -359,7 +368,7 @@ describe("work command", () => {
359
368
  expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
360
369
  })
361
370
 
362
- test("selects a target with fzf when no directory is provided", async () => {
371
+ test("selects a target when no directory is provided", async () => {
363
372
  const phase = {
364
373
  taskId: "delivery",
365
374
  id: "build",
@@ -385,7 +394,6 @@ describe("work command", () => {
385
394
  await harness.run({ cwd: "/workbase/tasks/example", opencode: true }, pick)
386
395
 
387
396
  expect(harness.events).toEqual([
388
- "probe:fzf",
389
397
  "materialize",
390
398
  "probe:opencode",
391
399
  "launch:opencode",
@@ -442,7 +450,6 @@ describe("work command", () => {
442
450
 
443
451
  expect(selections).toEqual([["/first", "/workbase"]])
444
452
  expect(harness.events).toEqual([
445
- "probe:fzf",
446
453
  "materialize",
447
454
  "probe:opencode",
448
455
  "launch:opencode",
@@ -476,9 +483,9 @@ describe("work command", () => {
476
483
  expect(harness.events).toEqual([])
477
484
  })
478
485
 
479
- test("prints the target tree when fzf is unavailable", async () => {
486
+ test("passes the configured chooser command to the shared picker", async () => {
480
487
  const harness = createHarness({
481
- available: { fzf: false },
488
+ chooserCommand: ["gum", "filter"],
482
489
  epicRecords: [
483
490
  {
484
491
  id: "delivery",
@@ -487,14 +494,15 @@ describe("work command", () => {
487
494
  },
488
495
  ],
489
496
  })
497
+ let command: readonly string[] | undefined
498
+ const pick: PickWorkTarget = (_choices, chooserCommand) => {
499
+ command = chooserCommand
500
+ return Effect.succeed(null)
501
+ }
490
502
 
491
- const logs = await captureLogs(async () => {
492
- await expect(harness.run({ cwd: "/workbase" })).rejects.toThrow(
493
- "fzf is required",
494
- )
495
- })
503
+ await harness.run({ cwd: "/workbase" }, pick)
496
504
 
497
- expect(logs).toEqual(["\x1b[35m\x1b[0m delivery"])
505
+ expect(command).toEqual(["gum", "filter"])
498
506
  expect(harness.launches).toEqual([])
499
507
  })
500
508
 
@@ -89,7 +89,7 @@ export const work = (
89
89
  : false
90
90
  const startPath = isDirectory && directoryPath ? directoryPath : cwd
91
91
  const inputAllowed = options.inputAllowed ?? true
92
- const root = yield* resolveWorkbase(startPath, log, pickBase, inputAllowed)
92
+ const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
93
93
  if (!root) return
94
94
 
95
95
  let target: WorkTarget | null = null
@@ -178,18 +178,8 @@ export const work = (
178
178
  new Error("No epics, tasks, or phases found in this workbase"),
179
179
  )
180
180
  }
181
- const fzf = yield* fs.runCommand(["which", "fzf"], {
182
- captureOutput: true,
183
- })
184
- if (fzf.exitCode !== 0) {
185
- for (const choice of choices) log(choice.label)
186
- return yield* Effect.fail(
187
- new Error(
188
- "fzf is required to select a work target; install fzf or provide a directory explicitly",
189
- ),
190
- )
191
- }
192
- target = yield* pick(choices)
181
+ const { config } = yield* workbase.loadConfig(root)
182
+ target = yield* pick(choices, config.chooserCommand)
193
183
  if (!target) return
194
184
  }
195
185
 
@@ -332,7 +322,7 @@ Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
332
322
  agency work prepare [target] [--dry-run] [--json]
333
323
 
334
324
  Launch an agent for an epic, task, or phase. With no directory, select one
335
- with fzf. A positional argument resolves as a directory first, then as a task
325
+ interactively. A positional argument resolves as a directory first, then as a task
336
326
  ID. Use '.' for the current directory. Outside a workbase, select a registered
337
327
  workbase first.
338
328
 
@@ -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
  }),