@markjaquith/agency 3.5.2 → 3.6.1
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 +160 -16
- package/cli-main.ts +13 -2
- package/package.json +11 -5
- package/src/act-schema.ts +62 -0
- package/src/cli-parser.ts +13 -3
- package/src/commands/act-actions.ts +734 -0
- package/src/commands/act-prompts.ts +294 -0
- package/src/commands/act.ts +602 -436
- package/src/utils/chooser.ts +7 -0
- package/src/utils/interactive.js +977 -0
- package/src/workbase/AGENTS.md +49 -0
- package/src/workbase/work-target.ts +6 -4
- package/src/utils/interactive.tsx +0 -496
|
@@ -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>
|