@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 +23 -1
- package/package.json +1 -1
- package/src/commands/task.ts +31 -34
- package/src/commands/validate.ts +1 -6
- package/src/commands/work.test.ts +20 -12
- package/src/commands/work.ts +4 -14
- package/src/utils/chooser.test.ts +108 -0
- package/src/utils/chooser.ts +222 -0
- package/src/workbase/schemas.test.ts +12 -0
- package/src/workbase/schemas.ts +1 -0
- package/src/workbase/work-target.test.ts +10 -0
- package/src/workbase/work-target.ts +45 -35
- package/src/workbase/workbase-choice.ts +12 -37
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
package/src/commands/task.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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*
|
|
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*
|
|
101
|
+
(yield* activeInteraction.text("Ticket URL (optional): ")).trim() ||
|
|
102
|
+
null
|
|
107
103
|
}
|
|
108
104
|
if (options.description === undefined) {
|
|
109
105
|
description =
|
|
110
|
-
(yield*
|
|
111
|
-
|
|
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*
|
|
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*
|
|
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*
|
|
144
|
+
const selected = yield* activeInteraction.select(
|
|
148
145
|
"Writable repository",
|
|
149
146
|
records.map((record) => record.alias),
|
|
150
147
|
)
|
package/src/commands/validate.ts
CHANGED
|
@@ -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"
|
|
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
|
|
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("
|
|
486
|
+
test("passes the configured chooser command to the shared picker", async () => {
|
|
480
487
|
const harness = createHarness({
|
|
481
|
-
|
|
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
|
-
|
|
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(
|
|
505
|
+
expect(command).toEqual(["gum", "filter"])
|
|
498
506
|
expect(harness.launches).toEqual([])
|
|
499
507
|
})
|
|
500
508
|
|
package/src/commands/work.ts
CHANGED
|
@@ -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,
|
|
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
|
|
182
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
) => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
}),
|