@markjaquith/agency 3.5.1 → 3.6.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.
@@ -0,0 +1,294 @@
1
+ import { Data, Effect } from "effect"
2
+ import { Schema } from "@effect/schema"
3
+ import { EntityId } from "../workbase/schemas"
4
+ import { choose, type Choice } from "../utils/chooser"
5
+
6
+ export interface ActInteraction {
7
+ readonly tabs?: <T>(
8
+ tabs: readonly ActTab<T>[],
9
+ ) => Effect.Effect<T | null, Error>
10
+ readonly text?: (prompt: string) => Effect.Effect<string | null, Error>
11
+ readonly select: <T>(
12
+ prompt: string,
13
+ choices: readonly Choice<T>[],
14
+ command?: readonly string[],
15
+ ) => Effect.Effect<T | null, Error>
16
+ }
17
+
18
+ interface ActTab<T> {
19
+ readonly id: string
20
+ readonly label: string
21
+ readonly prompt: string
22
+ readonly choices: readonly Choice<T>[]
23
+ readonly emptyLabel?: string
24
+ }
25
+
26
+ export class ActCancelled extends Data.TaggedError("ActCancelled") {}
27
+ class ActInputError extends Data.TaggedError("ActInputError")<{
28
+ readonly message: string
29
+ }> {}
30
+
31
+ /**
32
+ * Collect inputs only: this callback must not execute operations or mutate work.
33
+ * Going back replays its answered prefix, then reopens the preceding prompt.
34
+ * Cache text and stable choice keys, never generic values from a previous run.
35
+ */
36
+ export const wizardInputs = <T>(
37
+ interaction: ActInteraction,
38
+ collect: (interaction: ActInteraction) => Effect.Effect<T, Error>,
39
+ canGoBack: () => boolean,
40
+ onInputError: (message?: string) => void = () => {},
41
+ ): Effect.Effect<T, Error> =>
42
+ Effect.suspend(() => {
43
+ const answers: string[] = []
44
+ let cursor = 0
45
+ const ask = (read: () => Effect.Effect<string | null, Error>) =>
46
+ Effect.suspend(() => {
47
+ const index = cursor++
48
+ if (index < answers.length) return Effect.succeed(answers[index]!)
49
+ return read().pipe(
50
+ Effect.tap((answer) =>
51
+ Effect.sync(() => {
52
+ if (answer !== null) {
53
+ answers.push(answer)
54
+ onInputError()
55
+ }
56
+ }),
57
+ ),
58
+ )
59
+ })
60
+ const ui: ActInteraction = {
61
+ ...interaction,
62
+ text: (prompt) => ask(() => (interaction.text ?? readText)(prompt)),
63
+ select: (prompt, choices, command) =>
64
+ ask(() =>
65
+ interaction.select(
66
+ prompt,
67
+ choices.map((choice) => ({ ...choice, value: choice.key })),
68
+ command,
69
+ ),
70
+ ).pipe(
71
+ Effect.flatMap((key) => {
72
+ if (key === null) return Effect.succeed(null)
73
+ const choice = choices.find((choice) => choice.key === key)
74
+ return choice
75
+ ? Effect.succeed(choice.value)
76
+ : Effect.fail(
77
+ new Error(`Wizard choice '${key}' is no longer available`),
78
+ )
79
+ }),
80
+ ),
81
+ }
82
+ const run = (): Effect.Effect<T, Error> =>
83
+ Effect.suspend(() => {
84
+ cursor = 0
85
+ return collect(ui).pipe(
86
+ Effect.catchIf(
87
+ (error): error is ActInputError => error instanceof ActInputError,
88
+ (error) => {
89
+ if (!cursor || !canGoBack()) return Effect.fail(error)
90
+ return Effect.sync(() => {
91
+ answers.length = cursor - 1
92
+ onInputError(error.message)
93
+ }).pipe(Effect.zipRight(run()))
94
+ },
95
+ ),
96
+ Effect.catchIf(
97
+ (error): error is ActCancelled => error instanceof ActCancelled,
98
+ (error) => {
99
+ if (cursor < 2 || !canGoBack()) return Effect.fail(error)
100
+ answers.length = cursor - 2
101
+ onInputError()
102
+ return run()
103
+ },
104
+ ),
105
+ )
106
+ })
107
+ return run()
108
+ })
109
+
110
+ export const actTabs = [
111
+ { id: "workstream", label: " Workstream", prompt: "" },
112
+ { id: "workbase", label: " Workbase", prompt: "" },
113
+ ] as const
114
+
115
+ const readText = (prompt: string) =>
116
+ Effect.tryPromise({
117
+ try: async () => {
118
+ const { loadInteractive } = await import("../utils/interactive-loader")
119
+ try {
120
+ return await (await loadInteractive()).promptText(prompt)
121
+ } catch (cause) {
122
+ if (
123
+ cause instanceof Error &&
124
+ cause.message === "Interactive input cancelled"
125
+ )
126
+ return null
127
+ throw cause
128
+ }
129
+ },
130
+ catch: (cause) => new Error("Failed to read action input", { cause }),
131
+ })
132
+
133
+ export const defaultInteraction: ActInteraction = {
134
+ select: choose,
135
+ text: readText,
136
+ }
137
+
138
+ export const openActSession = () =>
139
+ Effect.tryPromise({
140
+ try: async () => {
141
+ const { loadInteractive } = await import("../utils/interactive-loader")
142
+ let cancel!: () => void
143
+ let cancellation: Promise<void>
144
+ const resetCancellation = () => {
145
+ cancellation = new Promise<void>((resolve) => {
146
+ cancel = resolve
147
+ })
148
+ }
149
+ resetCancellation()
150
+ const session = await (
151
+ await loadInteractive()
152
+ ).createInteractiveSession(
153
+ () => cancel(),
154
+ actTabs.map((tab) => ({ ...tab, choices: [] })),
155
+ )
156
+ const attempt = <T>(run: () => Promise<T>) =>
157
+ Effect.tryPromise({
158
+ try: run,
159
+ catch: (cause) => new Error("Interactive session failed", { cause }),
160
+ })
161
+ const interaction: ActInteraction = {
162
+ tabs: (tabs) =>
163
+ attempt(async () => {
164
+ const key = await session.tabs(
165
+ tabs.map((tab) => ({
166
+ ...tab,
167
+ choices: tab.choices.map((choice) => ({
168
+ ...choice,
169
+ label: choice.plainLabel ?? choice.label,
170
+ })),
171
+ })),
172
+ )
173
+ if (key === null) return null
174
+ const choice = tabs
175
+ .flatMap((tab) => tab.choices)
176
+ .find((choice) => choice.key === key)
177
+ if (!choice) throw new Error("Invalid tab selection")
178
+ return choice.value
179
+ }),
180
+ text: (prompt) => attempt(() => session.text(prompt)),
181
+ select: (prompt, choices) =>
182
+ attempt(async () => {
183
+ const key = await session.select(
184
+ prompt,
185
+ choices.map((choice) => ({
186
+ ...choice,
187
+ label: choice.plainLabel ?? choice.label,
188
+ })),
189
+ )
190
+ if (key === null) return null
191
+ const choice = choices.find((choice) => choice.key === key)
192
+ if (!choice) throw new Error("Invalid interactive selection")
193
+ return choice.value
194
+ }),
195
+ }
196
+ return {
197
+ activateTab: session.activateTab,
198
+ takeNavigation: session.takeNavigation,
199
+ get quitRequested() {
200
+ return session.quitRequested
201
+ },
202
+ get navigationRequested() {
203
+ return session.navigationRequested
204
+ },
205
+ resetCancellation,
206
+ notice: session.notice,
207
+ cancelled: Effect.promise(() => cancellation).pipe(
208
+ Effect.flatMap(() => Effect.fail(new ActCancelled())),
209
+ ),
210
+ interaction,
211
+ show: session.show,
212
+ close: () => Effect.promise(session.close),
213
+ }
214
+ },
215
+ catch: (cause) =>
216
+ new Error("Failed to open interactive session", { cause }),
217
+ })
218
+ const isEntityId = Schema.is(EntityId)
219
+ const slug = (text: string) =>
220
+ text
221
+ .toLowerCase()
222
+ .replace(/[^a-z0-9]+/g, "-")
223
+ .replace(/^-|-$/g, "")
224
+ .split("-")
225
+ .slice(0, 5)
226
+ .join("-")
227
+ .slice(0, 60)
228
+ .replace(/-$/, "")
229
+
230
+ export const actionPrompts = (
231
+ interaction: ActInteraction,
232
+ repositories: readonly string[],
233
+ keys: readonly string[],
234
+ chooser?: readonly string[],
235
+ ) => {
236
+ const text = (label: string, fallback = "", required = true) =>
237
+ Effect.gen(function* () {
238
+ const value = yield* (interaction.text ?? readText)(
239
+ `${label}${fallback ? ` [${fallback}]` : ""}: `,
240
+ )
241
+ if (value === null) return yield* Effect.fail(new ActCancelled())
242
+ const answer = value.trim() || fallback
243
+ if (required && !answer)
244
+ return yield* Effect.fail(
245
+ new ActInputError({ message: `${label} is required` }),
246
+ )
247
+ return answer
248
+ })
249
+ return {
250
+ text,
251
+ id: (label: string, suggestion: string, prefix = "") => {
252
+ const base = slug(suggestion)
253
+ let candidate = base
254
+ let suffix = 2
255
+ while (candidate && keys.includes(prefix + candidate))
256
+ candidate = `${base}-${suffix++}`
257
+ return text(label, candidate).pipe(
258
+ Effect.flatMap((value) => {
259
+ if (!isEntityId(value))
260
+ return Effect.fail(
261
+ new ActInputError({
262
+ message: `${label} must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens`,
263
+ }),
264
+ )
265
+ if (keys.includes(prefix + value))
266
+ return Effect.fail(
267
+ new ActInputError({
268
+ message: `${label} '${value}' is already in use`,
269
+ }),
270
+ )
271
+ return Effect.succeed(value)
272
+ }),
273
+ )
274
+ },
275
+ repository: (preferred?: string) =>
276
+ Effect.gen(function* () {
277
+ if (!repositories.length)
278
+ return yield* Effect.fail(new Error("Add or link a repository first"))
279
+ if (repositories.length === 1) return repositories[0]!
280
+ const ordered = preferred
281
+ ? [preferred, ...repositories.filter((repo) => repo !== preferred)]
282
+ : repositories
283
+ const selected = yield* interaction.select(
284
+ "Repository alias",
285
+ ordered.map((value) => ({ key: value, label: value, value })),
286
+ chooser,
287
+ )
288
+ if (selected === null) return yield* Effect.fail(new ActCancelled())
289
+ return selected
290
+ }),
291
+ }
292
+ }
293
+
294
+ export type ActionPrompts = ReturnType<typeof actionPrompts>