@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/README.md +43 -2
- package/cli.ts +21 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +15 -0
- package/src/cli-parser.ts +15 -0
- package/src/commands/sync.ts +36 -0
- 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/protocol.ts +5 -0
- package/src/services/ClaimService.ts +94 -0
- package/src/services/SyncService.test.ts +364 -0
- package/src/services/SyncService.ts +738 -0
- package/src/test-utils.ts +2 -0
- 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/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
|
})
|
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
|
}),
|