@markjaquith/agency 2.30.1 → 2.32.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 +56 -38
- package/cli.ts +2 -1
- package/package.json +5 -2
- package/skills/agency/SKILL.md +7 -6
- package/skills/agency/references/commands.md +17 -14
- package/skills/agency/references/contracts.md +4 -5
- package/skills/agency/references/recipes.md +13 -10
- package/src/cli-parser.test.ts +12 -2
- package/src/cli-parser.ts +5 -2
- package/src/cli.test.ts +34 -10
- package/src/commands/doctor.test.ts +11 -1
- package/src/commands/phase.ts +1 -1
- package/src/commands/task.test.ts +34 -1
- package/src/commands/task.ts +16 -20
- package/src/commands/work.test.ts +65 -65
- package/src/commands/work.ts +43 -35
- package/src/opentui.d.ts +1 -0
- package/src/services/DoctorService.ts +22 -0
- package/src/services/EpicService.test.ts +3 -0
- package/src/services/EpicService.ts +2 -1
- package/src/services/IntegrationService.test.ts +2 -1
- package/src/services/PhaseService.ts +5 -4
- package/src/services/PullRequestService.test.ts +4 -1
- package/src/services/ReadinessService.test.ts +54 -0
- package/src/services/ReadinessService.ts +39 -2
- package/src/services/TaskPhaseService.test.ts +38 -11
- package/src/services/TaskService.ts +4 -3
- package/src/utils/chooser.test.ts +49 -12
- package/src/utils/chooser.ts +20 -37
- package/src/utils/interactive-loader.ts +6 -0
- package/src/utils/interactive.test.tsx +258 -0
- package/src/utils/interactive.tsx +290 -0
- package/src/workbase/AGENTS.md +5 -5
- package/src/workbase/frontmatter.ts +17 -0
- package/src/workbase/runner-command.test.ts +30 -4
- package/src/workbase/runner-command.ts +21 -6
- package/src/workbase/schemas.test.ts +4 -2
- package/src/workbase/schemas.ts +4 -0
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "../workbase/schemas"
|
|
11
11
|
import {
|
|
12
12
|
formatMarkdownDocument,
|
|
13
|
+
formatWorkDocumentBody,
|
|
13
14
|
parseFrontmatter,
|
|
14
15
|
} from "../workbase/frontmatter"
|
|
15
16
|
import { documentRevision } from "../workbase/document-revision"
|
|
@@ -95,7 +96,7 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
|
|
|
95
96
|
.join(" ")
|
|
96
97
|
const content = formatMarkdownDocument(
|
|
97
98
|
data,
|
|
98
|
-
|
|
99
|
+
formatWorkDocumentBody(title, "epic"),
|
|
99
100
|
)
|
|
100
101
|
yield* fs.writeFile(path, content)
|
|
101
102
|
return {
|
|
@@ -76,7 +76,8 @@ describe("IntegrationService", () => {
|
|
|
76
76
|
|
|
77
77
|
expect(body).toContain("agency context . --json")
|
|
78
78
|
expect(body).toContain("authority.writable.checkoutPath")
|
|
79
|
-
expect(body).toContain("
|
|
79
|
+
expect(body).toContain("`agency work` is the local launch flow")
|
|
80
|
+
expect(body).toContain("External orchestrators claim before launching")
|
|
80
81
|
expect(body).toContain("Run `agency validate`")
|
|
81
82
|
expect(body).toContain("only with explicit user intent")
|
|
82
83
|
expect(body).toContain("An execution unit is `working`")
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../workbase/schemas"
|
|
15
15
|
import {
|
|
16
16
|
formatMarkdownDocument,
|
|
17
|
+
formatWorkDocumentBody,
|
|
17
18
|
parseFrontmatter,
|
|
18
19
|
} from "../workbase/frontmatter"
|
|
19
20
|
import { canTransitionStatus } from "../readiness"
|
|
@@ -196,7 +197,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
196
197
|
.join(" ")
|
|
197
198
|
const content = formatMarkdownDocument(
|
|
198
199
|
data,
|
|
199
|
-
|
|
200
|
+
formatWorkDocumentBody(title, "phase"),
|
|
200
201
|
)
|
|
201
202
|
|
|
202
203
|
if (!isMultiPhase) {
|
|
@@ -246,7 +247,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
246
247
|
const firstPhasePath = join(firstDirectory, "PHASE.md")
|
|
247
248
|
const firstContent = formatMarkdownDocument(
|
|
248
249
|
firstData,
|
|
249
|
-
|
|
250
|
+
formatWorkDocumentBody(firstTitle, "phase"),
|
|
250
251
|
)
|
|
251
252
|
const steps: TransactionStep[] = []
|
|
252
253
|
if (yield* fs.isDirectory(oldCodePath)) {
|
|
@@ -473,10 +474,10 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
473
474
|
const fs = yield* FileSystemService
|
|
474
475
|
const service = yield* PhaseService
|
|
475
476
|
const validStatus = yield* decodeStatus(status)
|
|
476
|
-
if (validStatus === "
|
|
477
|
+
if (validStatus === "delegated") {
|
|
477
478
|
return yield* new PhaseError({
|
|
478
479
|
message:
|
|
479
|
-
"
|
|
480
|
+
"Delegation requires explicit ownership; use 'agency claim'",
|
|
480
481
|
})
|
|
481
482
|
}
|
|
482
483
|
const record = yield* service.show(taskId, id, startPath)
|
|
@@ -220,7 +220,10 @@ process.exit(${exitCode})
|
|
|
220
220
|
const original = await Bun.file(taskPath).text()
|
|
221
221
|
await Bun.write(
|
|
222
222
|
taskPath,
|
|
223
|
-
original.replace(
|
|
223
|
+
original.replace(
|
|
224
|
+
"# Example\n\n## Outcome\n\nDescribe the task outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
|
|
225
|
+
body,
|
|
226
|
+
),
|
|
224
227
|
)
|
|
225
228
|
const url = "https://github.com/example/agency/pull/42"
|
|
226
229
|
await writeFakeGh({ stdout: `${url}\n` })
|
|
@@ -203,6 +203,60 @@ describe("ReadinessService", () => {
|
|
|
203
203
|
await service((readiness) =>
|
|
204
204
|
readiness.guardWorkTarget("phase:ship/implement", root),
|
|
205
205
|
)
|
|
206
|
+
expect(
|
|
207
|
+
[
|
|
208
|
+
...(await service((readiness) => readiness.getWorkTargetIds(root))),
|
|
209
|
+
].sort(),
|
|
210
|
+
).toEqual([...readyIds].sort())
|
|
211
|
+
|
|
212
|
+
await write(
|
|
213
|
+
root,
|
|
214
|
+
"tasks/ship/phases/implement/PHASE.md",
|
|
215
|
+
execution("working", "feat/implement"),
|
|
216
|
+
)
|
|
217
|
+
const resumableIds = await service((readiness) =>
|
|
218
|
+
readiness.getWorkTargetIds(root),
|
|
219
|
+
)
|
|
220
|
+
expect(resumableIds).toContain("epic:delivery")
|
|
221
|
+
expect(resumableIds).toContain("task:ship")
|
|
222
|
+
expect(resumableIds).toContain("execution-unit:phase/ship/implement")
|
|
223
|
+
await service((readiness) =>
|
|
224
|
+
readiness.guardWorkTarget("execution-unit:phase/ship/implement", root),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
await write(
|
|
228
|
+
root,
|
|
229
|
+
"tasks/ship/phases/implement/PHASE.md",
|
|
230
|
+
`---
|
|
231
|
+
repo: agency
|
|
232
|
+
branch: feat/implement
|
|
233
|
+
base: main
|
|
234
|
+
pr: null
|
|
235
|
+
status: working
|
|
236
|
+
claim:
|
|
237
|
+
claimant: orchestrator
|
|
238
|
+
runner: opencode
|
|
239
|
+
sessionId: session-1
|
|
240
|
+
startedAt: 2026-07-20T00:00:00.000Z
|
|
241
|
+
targetRevision: ${"a".repeat(64)}
|
|
242
|
+
state: active
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
# Execution
|
|
246
|
+
`,
|
|
247
|
+
)
|
|
248
|
+
expect(
|
|
249
|
+
await service((readiness) => readiness.getWorkTargetIds(root)),
|
|
250
|
+
).not.toContain("execution-unit:phase/ship/implement")
|
|
251
|
+
await expect(
|
|
252
|
+
service((readiness) =>
|
|
253
|
+
readiness.guardWorkTarget(
|
|
254
|
+
"execution-unit:phase/ship/implement",
|
|
255
|
+
root,
|
|
256
|
+
true,
|
|
257
|
+
),
|
|
258
|
+
),
|
|
259
|
+
).rejects.toThrow("active claim")
|
|
206
260
|
|
|
207
261
|
const blocked = await service((readiness) =>
|
|
208
262
|
Effect.either(readiness.guardWorkTarget("phase:ship/verify", root)),
|
|
@@ -46,6 +46,23 @@ const executionNodeId = (taskId: string, phaseId?: string) =>
|
|
|
46
46
|
? `execution-unit:phase/${taskId}/${phaseId}`
|
|
47
47
|
: `execution-unit:task/${taskId}`
|
|
48
48
|
|
|
49
|
+
const hasActiveClaim = (node: GraphNode) =>
|
|
50
|
+
node.kind !== "epic" &&
|
|
51
|
+
node.kind !== "repository" &&
|
|
52
|
+
"claim" in node.data &&
|
|
53
|
+
node.data.claim?.state === "active"
|
|
54
|
+
|
|
55
|
+
const isResumableWork = (node: GraphNode) =>
|
|
56
|
+
node.kind !== "repository" &&
|
|
57
|
+
node.status === "working" &&
|
|
58
|
+
!hasActiveClaim(node) &&
|
|
59
|
+
node.readiness.blockers.every((blocker) => blocker.kind !== "validation")
|
|
60
|
+
|
|
61
|
+
const isWorkTarget = (node: GraphNode) =>
|
|
62
|
+
node.kind !== "repository" &&
|
|
63
|
+
!hasActiveClaim(node) &&
|
|
64
|
+
(node.readiness.ready || isResumableWork(node))
|
|
65
|
+
|
|
49
66
|
const itemFor = (
|
|
50
67
|
node: ExecutionNode,
|
|
51
68
|
graph: AgencyGraph,
|
|
@@ -126,6 +143,15 @@ export class ReadinessService extends Effect.Service<ReadinessService>()(
|
|
|
126
143
|
)
|
|
127
144
|
}),
|
|
128
145
|
|
|
146
|
+
getWorkTargetIds: (cwd: string = process.cwd()) =>
|
|
147
|
+
Effect.gen(function* () {
|
|
148
|
+
const graphs = yield* GraphService
|
|
149
|
+
const graph = yield* graphs.get({ cwd })
|
|
150
|
+
return new Set(
|
|
151
|
+
graph.nodes.filter(isWorkTarget).map((node) => node.id),
|
|
152
|
+
)
|
|
153
|
+
}),
|
|
154
|
+
|
|
129
155
|
getNext: (cwd: string = process.cwd(), select = false) =>
|
|
130
156
|
Effect.gen(function* () {
|
|
131
157
|
const graphs = yield* GraphService
|
|
@@ -150,11 +176,11 @@ export class ReadinessService extends Effect.Service<ReadinessService>()(
|
|
|
150
176
|
override = false,
|
|
151
177
|
) =>
|
|
152
178
|
Effect.gen(function* () {
|
|
153
|
-
if (override) return
|
|
154
179
|
const graphs = yield* GraphService
|
|
155
180
|
const graph = yield* graphs.get({ cwd })
|
|
156
181
|
const node = graph.nodes.find((candidate) => candidate.id === target)
|
|
157
182
|
if (!node || !node.readiness) {
|
|
183
|
+
if (override) return
|
|
158
184
|
return yield* new ExecutionGuardError({
|
|
159
185
|
message: `Work target '${target}' was not found in the work graph.`,
|
|
160
186
|
action: "work",
|
|
@@ -164,7 +190,18 @@ export class ReadinessService extends Effect.Service<ReadinessService>()(
|
|
|
164
190
|
blockers: [],
|
|
165
191
|
})
|
|
166
192
|
}
|
|
167
|
-
if (
|
|
193
|
+
if (hasActiveClaim(node)) {
|
|
194
|
+
return yield* new ExecutionGuardError({
|
|
195
|
+
message: `Cannot work on '${node.key}': it has an active claim. Use agency release or agency finish first.`,
|
|
196
|
+
action: "work",
|
|
197
|
+
target,
|
|
198
|
+
status: node.status!,
|
|
199
|
+
blockedBy: node.readiness!.blockedBy,
|
|
200
|
+
blockers: node.readiness!.blockers,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
if (override) return
|
|
204
|
+
if (!node.readiness.ready && !isResumableWork(node)) {
|
|
168
205
|
const item = {
|
|
169
206
|
key: node.key,
|
|
170
207
|
status: node.status!,
|
|
@@ -33,7 +33,7 @@ describe("task and phase services", () => {
|
|
|
33
33
|
),
|
|
34
34
|
),
|
|
35
35
|
)
|
|
36
|
-
await runTestEffect(
|
|
36
|
+
const createdTask = await runTestEffect(
|
|
37
37
|
TaskService.pipe(
|
|
38
38
|
Effect.flatMap((service) =>
|
|
39
39
|
service.create(
|
|
@@ -51,6 +51,9 @@ describe("task and phase services", () => {
|
|
|
51
51
|
),
|
|
52
52
|
),
|
|
53
53
|
)
|
|
54
|
+
expect(createdTask.content).toContain(
|
|
55
|
+
"# Task One\n\n## Outcome\n\nDescribe the task outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
|
|
56
|
+
)
|
|
54
57
|
|
|
55
58
|
const epic = await runTestEffect(
|
|
56
59
|
EpicService.pipe(
|
|
@@ -159,6 +162,14 @@ describe("task and phase services", () => {
|
|
|
159
162
|
),
|
|
160
163
|
)
|
|
161
164
|
expect(phases.map((phase) => phase.id)).toEqual(["first", "second"])
|
|
165
|
+
const firstPhase = await runTestEffect(
|
|
166
|
+
PhaseService.pipe(
|
|
167
|
+
Effect.flatMap((service) => service.show("multi", "first", root)),
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
expect(firstPhase.content).toContain(
|
|
171
|
+
"# First\n\n## Outcome\n\nDescribe the phase outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
|
|
172
|
+
)
|
|
162
173
|
})
|
|
163
174
|
|
|
164
175
|
test("converts a single-phase task when the existing phase ID is provided", async () => {
|
|
@@ -243,7 +254,9 @@ describe("task and phase services", () => {
|
|
|
243
254
|
{ id: "extra", dependsOn: ["implementation"] },
|
|
244
255
|
],
|
|
245
256
|
})
|
|
246
|
-
expect(task.content).toContain(
|
|
257
|
+
expect(task.content).toContain(
|
|
258
|
+
"## Outcome\n\nDescribe the task outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions",
|
|
259
|
+
)
|
|
247
260
|
|
|
248
261
|
const firstPhase = await runTestEffect(
|
|
249
262
|
PhaseService.pipe(
|
|
@@ -288,17 +301,23 @@ describe("task and phase services", () => {
|
|
|
288
301
|
),
|
|
289
302
|
)
|
|
290
303
|
expect(createdTask.content).toContain("status: open")
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
304
|
+
const workingTask = await runTestEffect(
|
|
305
|
+
TaskService.pipe(
|
|
306
|
+
Effect.flatMap((service) =>
|
|
307
|
+
service.setStatus("single-status", "working", root),
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
expect(workingTask.data.status).toBe("working")
|
|
312
|
+
await expect(
|
|
313
|
+
runTestEffect(
|
|
314
|
+
TaskService.pipe(
|
|
315
|
+
Effect.flatMap((service) =>
|
|
316
|
+
service.setStatus("single-status", "delegated", root),
|
|
298
317
|
),
|
|
299
318
|
),
|
|
300
|
-
)
|
|
301
|
-
|
|
319
|
+
),
|
|
320
|
+
).rejects.toThrow("Delegation requires explicit ownership")
|
|
302
321
|
await runTestEffect(
|
|
303
322
|
TaskService.pipe(
|
|
304
323
|
Effect.flatMap((service) =>
|
|
@@ -346,6 +365,14 @@ describe("task and phase services", () => {
|
|
|
346
365
|
),
|
|
347
366
|
),
|
|
348
367
|
)
|
|
368
|
+
const workingPhase = await runTestEffect(
|
|
369
|
+
PhaseService.pipe(
|
|
370
|
+
Effect.flatMap((service) =>
|
|
371
|
+
service.setStatus("multi-status", "implementation", "working", root),
|
|
372
|
+
),
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
expect(workingPhase.data.status).toBe("working")
|
|
349
376
|
const phase = await runTestEffect(
|
|
350
377
|
PhaseService.pipe(
|
|
351
378
|
Effect.flatMap((service) =>
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "../workbase/schemas"
|
|
14
14
|
import {
|
|
15
15
|
formatMarkdownDocument,
|
|
16
|
+
formatWorkDocumentBody,
|
|
16
17
|
parseFrontmatter,
|
|
17
18
|
} from "../workbase/frontmatter"
|
|
18
19
|
import { canTransitionStatus } from "../readiness"
|
|
@@ -163,7 +164,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
163
164
|
.join(" ")
|
|
164
165
|
const content = formatMarkdownDocument(
|
|
165
166
|
data,
|
|
166
|
-
|
|
167
|
+
formatWorkDocumentBody(title, "task"),
|
|
167
168
|
)
|
|
168
169
|
const writes: {
|
|
169
170
|
path: string
|
|
@@ -251,10 +252,10 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
251
252
|
const fs = yield* FileSystemService
|
|
252
253
|
const service = yield* TaskService
|
|
253
254
|
const validStatus = yield* decodeStatus(status)
|
|
254
|
-
if (validStatus === "
|
|
255
|
+
if (validStatus === "delegated") {
|
|
255
256
|
return yield* new TaskError({
|
|
256
257
|
message:
|
|
257
|
-
"
|
|
258
|
+
"Delegation requires explicit ownership; use 'agency claim'",
|
|
258
259
|
})
|
|
259
260
|
}
|
|
260
261
|
const record = yield* service.show(id, startPath)
|
|
@@ -9,43 +9,80 @@ const choices = [
|
|
|
9
9
|
|
|
10
10
|
const createIO = (
|
|
11
11
|
overrides: Partial<ChooserIO> = {},
|
|
12
|
-
): ChooserIO & {
|
|
13
|
-
|
|
12
|
+
): ChooserIO & {
|
|
13
|
+
readonly selections: Array<{
|
|
14
|
+
readonly prompt: string
|
|
15
|
+
readonly choices: readonly {
|
|
16
|
+
readonly key: string
|
|
17
|
+
readonly label: string
|
|
18
|
+
}[]
|
|
19
|
+
}>
|
|
20
|
+
readonly inputs: string[]
|
|
21
|
+
} => {
|
|
22
|
+
const selections: Array<{
|
|
23
|
+
readonly prompt: string
|
|
24
|
+
readonly choices: readonly {
|
|
25
|
+
readonly key: string
|
|
26
|
+
readonly label: string
|
|
27
|
+
}[]
|
|
28
|
+
}> = []
|
|
14
29
|
const inputs: string[] = []
|
|
15
30
|
return {
|
|
16
31
|
inputIsTTY: true,
|
|
17
32
|
outputIsTTY: true,
|
|
18
33
|
color: false,
|
|
19
|
-
|
|
20
|
-
|
|
34
|
+
select: async (prompt, choices) => {
|
|
35
|
+
selections.push({ prompt, choices })
|
|
36
|
+
return choices[0]?.key ?? null
|
|
37
|
+
},
|
|
21
38
|
run: async (_command, input) => {
|
|
22
39
|
inputs.push(input)
|
|
23
40
|
return { exitCode: 0, stdout: "first-key\n" }
|
|
24
41
|
},
|
|
25
42
|
...overrides,
|
|
26
|
-
|
|
43
|
+
selections,
|
|
27
44
|
inputs,
|
|
28
45
|
}
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
describe("chooser", () => {
|
|
32
|
-
test("offers
|
|
33
|
-
|
|
49
|
+
test("offers plain choices to the native interactive renderer", async () => {
|
|
50
|
+
let offered:
|
|
51
|
+
| {
|
|
52
|
+
readonly prompt: string
|
|
53
|
+
readonly choices: readonly {
|
|
54
|
+
readonly key: string
|
|
55
|
+
readonly label: string
|
|
56
|
+
}[]
|
|
57
|
+
}
|
|
58
|
+
| undefined
|
|
59
|
+
const io = createIO({
|
|
60
|
+
select: async (prompt, offeredChoices) => {
|
|
61
|
+
offered = { prompt, choices: offeredChoices }
|
|
62
|
+
return offeredChoices[1]!.key
|
|
63
|
+
},
|
|
64
|
+
})
|
|
34
65
|
|
|
35
66
|
const result = await Effect.runPromise(
|
|
36
67
|
choose("Pick one", choices, undefined, io),
|
|
37
68
|
)
|
|
38
69
|
|
|
39
70
|
expect(result).toBe(2)
|
|
40
|
-
expect(
|
|
71
|
+
expect(offered).toEqual({
|
|
72
|
+
prompt: "Pick one",
|
|
73
|
+
choices: [
|
|
74
|
+
{ key: "first-key", label: "First" },
|
|
75
|
+
{ key: "second key", label: "Second" },
|
|
76
|
+
],
|
|
77
|
+
})
|
|
41
78
|
})
|
|
42
79
|
|
|
43
|
-
test("preserves colors
|
|
80
|
+
test("preserves colors for configured external choosers", async () => {
|
|
44
81
|
const io = createIO({ color: true })
|
|
45
82
|
|
|
46
|
-
await Effect.runPromise(choose("Pick one", choices,
|
|
83
|
+
await Effect.runPromise(choose("Pick one", choices, ["chooser"], io))
|
|
47
84
|
|
|
48
|
-
expect(io.
|
|
85
|
+
expect(io.inputs[0]).toContain("\x1b[32mFirst\x1b[0m")
|
|
49
86
|
})
|
|
50
87
|
|
|
51
88
|
test("passes generic records to an external argv command", async () => {
|
|
@@ -70,7 +107,7 @@ describe("chooser", () => {
|
|
|
70
107
|
})
|
|
71
108
|
|
|
72
109
|
test("treats native and external cancellation as no selection", async () => {
|
|
73
|
-
const native = createIO({
|
|
110
|
+
const native = createIO({ select: async () => null })
|
|
74
111
|
const external = createIO({
|
|
75
112
|
run: async () => ({ exitCode: 130, stdout: "" }),
|
|
76
113
|
})
|
package/src/utils/chooser.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
-
import { createInterface } from "node:readline/promises"
|
|
3
2
|
|
|
4
3
|
export interface Choice<T> {
|
|
5
4
|
readonly key: string
|
|
@@ -35,8 +34,10 @@ export interface ChooserIO {
|
|
|
35
34
|
readonly inputIsTTY: boolean
|
|
36
35
|
readonly outputIsTTY: boolean
|
|
37
36
|
readonly color: boolean
|
|
38
|
-
readonly
|
|
39
|
-
|
|
37
|
+
readonly select: (
|
|
38
|
+
prompt: string,
|
|
39
|
+
choices: readonly { readonly key: string; readonly label: string }[],
|
|
40
|
+
) => Promise<string | null>
|
|
40
41
|
readonly run: (
|
|
41
42
|
command: readonly string[],
|
|
42
43
|
input: string,
|
|
@@ -51,22 +52,16 @@ const stripAnsi = (value: string) =>
|
|
|
51
52
|
|
|
52
53
|
const defaultIO = (): ChooserIO => ({
|
|
53
54
|
inputIsTTY: Boolean(process.stdin.isTTY),
|
|
54
|
-
outputIsTTY: Boolean(process.
|
|
55
|
+
outputIsTTY: Boolean(process.stdout.isTTY),
|
|
55
56
|
color:
|
|
56
|
-
Boolean(process.
|
|
57
|
+
Boolean(process.stdout.isTTY) &&
|
|
57
58
|
process.env.NO_COLOR === undefined &&
|
|
58
59
|
process.env.TERM !== "dumb",
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
})
|
|
65
|
-
try {
|
|
66
|
-
return await input.question(prompt)
|
|
67
|
-
} finally {
|
|
68
|
-
input.close()
|
|
69
|
-
}
|
|
60
|
+
select: async (prompt, choices) => {
|
|
61
|
+
const { promptSelect } = await (
|
|
62
|
+
await import("./interactive-loader")
|
|
63
|
+
).loadInteractive()
|
|
64
|
+
return promptSelect(prompt, choices)
|
|
70
65
|
},
|
|
71
66
|
run: async (command, input) => {
|
|
72
67
|
const child = Bun.spawn([...command], {
|
|
@@ -173,33 +168,21 @@ const nativeChoice = async <T>(
|
|
|
173
168
|
"Interactive selection requires a terminal; provide an explicit value or use --no-input",
|
|
174
169
|
)
|
|
175
170
|
}
|
|
176
|
-
|
|
177
|
-
for (const [index, choice] of choices.entries()) {
|
|
178
|
-
io.write(` ${index + 1}. ${displayLabel(choice, io.color)}\n`)
|
|
179
|
-
}
|
|
180
|
-
let answer: string
|
|
171
|
+
let key: string | null
|
|
181
172
|
try {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
173
|
+
key = await io.select(
|
|
174
|
+
prompt,
|
|
175
|
+
choices.map((choice) => ({
|
|
176
|
+
key: choice.key,
|
|
177
|
+
label: displayLabel(choice, false),
|
|
178
|
+
})),
|
|
179
|
+
)
|
|
185
180
|
} catch (cause) {
|
|
186
|
-
if (cause instanceof Error && cause.name === "AbortError") return null
|
|
187
181
|
throw new ChooserError("input-unavailable", "Failed to read selection", {
|
|
188
182
|
cause,
|
|
189
183
|
})
|
|
190
184
|
}
|
|
191
|
-
|
|
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
|
|
185
|
+
return key === null ? null : selectedChoice(key, choices)
|
|
203
186
|
}
|
|
204
187
|
|
|
205
188
|
export const choose = <T>(
|