@toist/in 0.7.1 → 0.8.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/src/cli.ts ADDED
@@ -0,0 +1,885 @@
1
+ #!/usr/bin/env bun
2
+ // 2121 toist
3
+
4
+ import { readFileSync } from "node:fs"
5
+ import { resolve } from "node:path"
6
+ import { createInterface } from "node:readline/promises"
7
+ import { parseYaml, validateSpec, type PipelineSpec } from "@toist/spec"
8
+ import { createRunnerClient, startRunner, type PipelineRunResponse, type RunnerClient } from "@toist/aja"
9
+ import { runSpec, type RunSpecResult, type TaskDescriptor, type ToistRuntime } from "@toist/core"
10
+ import {
11
+ deleteRunDescriptor,
12
+ getCliRuntime,
13
+ isResumableRuntime,
14
+ loadRunDescriptor,
15
+ resumeLocalStoredRun,
16
+ storeRunDescriptor,
17
+ type StoredRunDescriptor,
18
+ } from "./runtime.ts"
19
+ import { wizard } from "./wizard.ts"
20
+ import { upgrade } from "./upgrade.ts"
21
+
22
+ const args = process.argv.slice(2)
23
+ const cmd = args[0]
24
+ let stdinTextPromise: Promise<string> | null = null
25
+
26
+ interface RunCliOptions {
27
+ pathArg?: string
28
+ payloadArg?: string
29
+ specStdin: boolean
30
+ payloadStdin: boolean
31
+ suspendAsJson: boolean
32
+ ephemeral: boolean
33
+ remote?: string
34
+ }
35
+
36
+ interface ResumeCliOptions {
37
+ runId?: number
38
+ responseArg?: string
39
+ suspendAsJson: boolean
40
+ }
41
+
42
+ interface ServeCliOptions {
43
+ port: number
44
+ rootDir: string
45
+ memory: boolean
46
+ noUi: boolean
47
+ noWatch: boolean
48
+ }
49
+
50
+ interface TasksCliOptions {
51
+ remote?: string
52
+ format: "table" | "json"
53
+ }
54
+
55
+ interface CliTaskRef {
56
+ id: number
57
+ nodeId: string
58
+ kind: "human.input" | "error_review"
59
+ prompt: string
60
+ schema: unknown
61
+ assignee: string | null
62
+ responseToken: string
63
+ }
64
+
65
+ function help() {
66
+ console.log("toist <command> [options]\n")
67
+ console.log(" init run setup wizard")
68
+ console.log(" run <file> run a pipeline")
69
+ console.log(" --remote <url> run against a remote runner")
70
+ console.log(" --spec-stdin read spec YAML from stdin")
71
+ console.log(" --payload '<json>' read payload from flag")
72
+ console.log(" --payload @file.json read payload JSON from file")
73
+ console.log(" --payload-stdin read payload JSON from stdin")
74
+ console.log(" --suspend-as-json print only { runId, suspendedAt } on suspension")
75
+ console.log(" --ephemeral use in-memory runtime only")
76
+ console.log(" resume <runId> resume a suspended local run")
77
+ console.log(" serve start the durable runner")
78
+ console.log(" tasks list list open tasks")
79
+ console.log(" tasks answer <id> answer a task and resume its run")
80
+ console.log(" validate <file...> validate pipeline YAML files")
81
+ console.log(" plan <file> render the pipeline DAG")
82
+ console.log(" upgrade upgrade @toist/* deps")
83
+ console.log("")
84
+ console.log("No command runs the setup wizard.")
85
+ }
86
+
87
+ function parsePayloadText(input: string, source = "payload"): Record<string, unknown> {
88
+ try {
89
+ return JSON.parse(input) as Record<string, unknown>
90
+ } catch (err) {
91
+ throw new Error(`invalid JSON ${source}: ${(err as Error).message}`)
92
+ }
93
+ }
94
+
95
+ function parsePayloadArg(input?: string): Record<string, unknown> {
96
+ if (!input) return {}
97
+ if (input.startsWith("@")) {
98
+ const path = resolve(process.cwd(), input.slice(1))
99
+ return parsePayloadText(readFileSync(path, "utf8"), path)
100
+ }
101
+ return parsePayloadText(input)
102
+ }
103
+
104
+ function readPipelineFile(pathArg: string): { path: string; parsed: unknown } {
105
+ const path = resolve(process.cwd(), pathArg)
106
+ const raw = readFileSync(path, "utf8")
107
+ return { path, parsed: parseYaml(raw) }
108
+ }
109
+
110
+ function requireParsedSpec(value: unknown, path: string): PipelineSpec {
111
+ const result = validateSpec(value)
112
+ if (!result.ok || !result.parsed) {
113
+ const details = result.errors.map((error) => `- ${error}`).join("\n")
114
+ throw new Error(`Invalid: ${path}\n${details}`)
115
+ }
116
+ return result.parsed
117
+ }
118
+
119
+ async function readStdinText(): Promise<string> {
120
+ if (!stdinTextPromise) {
121
+ stdinTextPromise = new Promise<string>((resolveText, reject) => {
122
+ const chunks: Buffer[] = []
123
+ process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
124
+ process.stdin.on("end", () => resolveText(Buffer.concat(chunks).toString("utf8")))
125
+ process.stdin.on("error", reject)
126
+ })
127
+ }
128
+ return stdinTextPromise
129
+ }
130
+
131
+ function shouldUseDefaultPayloadStdin(opts: RunCliOptions): boolean {
132
+ return !opts.specStdin && !opts.payloadStdin && !opts.payloadArg && !process.stdin.isTTY
133
+ }
134
+
135
+ function canPromptInline(opts: RunCliOptions): boolean {
136
+ return !opts.specStdin && !opts.payloadStdin && !shouldUseDefaultPayloadStdin(opts)
137
+ }
138
+
139
+ async function loadRunSpec(opts: RunCliOptions): Promise<unknown> {
140
+ if (opts.specStdin) return parseYaml(await readStdinText())
141
+ if (!opts.pathArg) throw new Error("Usage: toist run <file> [--payload '<json>']")
142
+ return readPipelineFile(opts.pathArg).parsed
143
+ }
144
+
145
+ async function loadRunPayload(opts: RunCliOptions): Promise<Record<string, unknown>> {
146
+ if (opts.payloadStdin || shouldUseDefaultPayloadStdin(opts)) {
147
+ return parsePayloadText(await readStdinText(), "payload from stdin")
148
+ }
149
+ return parsePayloadArg(opts.payloadArg)
150
+ }
151
+
152
+ function normalizeRemoteBaseUrl(input: string): string {
153
+ if (input.startsWith(":")) return `http://localhost${input}`
154
+ return input
155
+ }
156
+
157
+ function parseJsonValue(value: string | null | undefined): unknown {
158
+ if (!value) return null
159
+ try { return JSON.parse(value) } catch { return value }
160
+ }
161
+
162
+ async function waitForRemoteRun(client: RunnerClient, runId: number): Promise<PipelineRunResponse> {
163
+ for (;;) {
164
+ const run = await client.runs.get(runId)
165
+ if (run.status === "running") {
166
+ await Bun.sleep(50)
167
+ continue
168
+ }
169
+
170
+ if (run.status === "done") {
171
+ return {
172
+ id: run.id,
173
+ pipeline: run.pipeline,
174
+ status: "done",
175
+ result: parseJsonValue(run.result),
176
+ steps: parseStepsJson(run.steps) as never,
177
+ } as PipelineRunResponse
178
+ }
179
+
180
+ if (run.status === "suspended") {
181
+ const tasks = await client.tasks.list({ status: "open", runId, limit: 1 })
182
+ if (tasks.length === 0) {
183
+ throw new Error(`run ${runId} is suspended but has no open task`)
184
+ }
185
+ const task = await client.tasks.get(tasks[0].id)
186
+ return {
187
+ id: run.id,
188
+ pipeline: run.pipeline,
189
+ status: "suspended",
190
+ suspendedAt: run.current_node ?? task.nodeId,
191
+ task: {
192
+ id: task.id,
193
+ kind: task.kind,
194
+ prompt: task.prompt,
195
+ schema: task.schema,
196
+ assignee: task.assignee,
197
+ responseToken: task.responseToken,
198
+ },
199
+ steps: parseStepsJson(run.steps) as never,
200
+ } as PipelineRunResponse
201
+ }
202
+
203
+ return {
204
+ id: run.id,
205
+ pipeline: run.pipeline,
206
+ status: "error",
207
+ error: run.error ?? "unknown error",
208
+ }
209
+ }
210
+ }
211
+
212
+ async function printRemoteRunEvents(client: RunnerClient, runId: number) {
213
+ for await (const event of client.runs.events(runId)) {
214
+ console.error(`[event] ${event.type}`)
215
+ }
216
+ }
217
+
218
+ function parseStepsJson(value: string | null): Array<Record<string, unknown>> {
219
+ const parsed = parseJsonValue(value)
220
+ return Array.isArray(parsed) ? parsed as Array<Record<string, unknown>> : []
221
+ }
222
+
223
+ async function printRunEvents(result: RunSpecResult) {
224
+ if (!result.events) return
225
+ for await (const event of result.events) {
226
+ console.error(`[event] ${event.type}`)
227
+ }
228
+ }
229
+
230
+ async function findOpenTask(runtime: ToistRuntime, runId: number): Promise<CliTaskRef | null> {
231
+ const tasks = await runtime.tasks.list({ runId, status: "open", limit: 1 })
232
+ if (tasks.length === 0) return null
233
+ const task = await runtime.tasks.get(tasks[0].id)
234
+ if (!task) return null
235
+ return toCliTask(task)
236
+ }
237
+
238
+ function sniffPromptMode(schema: unknown):
239
+ | { kind: "boolean" }
240
+ | { kind: "string" }
241
+ | { kind: "boolean-object"; key: string }
242
+ | { kind: "string-object"; key: string }
243
+ | { kind: "json" }
244
+ | { kind: "value" } {
245
+ if (!schema || typeof schema !== "object") return { kind: "value" }
246
+
247
+ const typed = schema as { type?: unknown; properties?: Record<string, { type?: unknown }> }
248
+ if (typed.type === "boolean") return { kind: "boolean" }
249
+ if (typed.type === "string") return { kind: "string" }
250
+
251
+ if (typed.type === "object" && typed.properties && typeof typed.properties === "object") {
252
+ const entries = Object.entries(typed.properties)
253
+ if (entries.length === 1) {
254
+ const [key, property] = entries[0]
255
+ if (property?.type === "boolean") return { kind: "boolean-object", key }
256
+ if (property?.type === "string") return { kind: "string-object", key }
257
+ }
258
+ return { kind: "json" }
259
+ }
260
+
261
+ return { kind: "value" }
262
+ }
263
+
264
+ function toCliTask(task: { id: number; nodeId: string; kind: string; prompt: string; schema: unknown; assignee: string | null; responseToken: string }): CliTaskRef {
265
+ if (task.kind !== "human.input" && task.kind !== "error_review") {
266
+ throw new Error(`unsupported task kind: ${task.kind}`)
267
+ }
268
+ return {
269
+ id: task.id,
270
+ nodeId: task.nodeId,
271
+ kind: task.kind,
272
+ prompt: task.prompt,
273
+ schema: task.schema,
274
+ assignee: task.assignee,
275
+ responseToken: task.responseToken,
276
+ }
277
+ }
278
+
279
+ function toCliTaskFromResult(
280
+ task: NonNullable<Extract<RunSpecResult, { status: "suspended" }>["task"]>,
281
+ nodeId: string,
282
+ ): CliTaskRef {
283
+ if (task.kind !== "human.input" && task.kind !== "error_review") {
284
+ throw new Error(`unsupported task kind: ${task.kind}`)
285
+ }
286
+ return {
287
+ id: task.id,
288
+ nodeId,
289
+ kind: task.kind,
290
+ prompt: task.prompt,
291
+ schema: task.schema,
292
+ assignee: task.assignee,
293
+ responseToken: task.responseToken,
294
+ }
295
+ }
296
+
297
+ async function promptTaskResponse(task: CliTaskRef): Promise<unknown> {
298
+ const mode = sniffPromptMode(task.schema)
299
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
300
+ try {
301
+ if (mode.kind === "boolean") {
302
+ const answer = (await rl.question(`⏸ ${task.nodeId} — ${task.prompt} [y/N]: `)).trim().toLowerCase()
303
+ return answer === "y" || answer === "yes" || answer === "true"
304
+ }
305
+
306
+ if (mode.kind === "boolean-object") {
307
+ const answer = (await rl.question(`⏸ ${task.nodeId} — ${task.prompt} [y/N]: `)).trim().toLowerCase()
308
+ return { [mode.key]: answer === "y" || answer === "yes" || answer === "true" }
309
+ }
310
+
311
+ const answer = await rl.question(`⏸ ${task.nodeId} — ${task.prompt}: `)
312
+ if (mode.kind === "string") return answer
313
+ if (mode.kind === "string-object") return { [mode.key]: answer }
314
+ if (mode.kind === "json") {
315
+ try { return JSON.parse(answer) } catch { return { response: answer } }
316
+ }
317
+ return { value: answer }
318
+ } finally {
319
+ rl.close()
320
+ }
321
+ }
322
+
323
+ function printFinalResult(result: RunSpecResult) {
324
+ const { events: _events, ...printable } = result
325
+ console.log(JSON.stringify(printable, null, 2))
326
+ }
327
+
328
+ function printSuspendAsJson(result: Extract<RunSpecResult, { status: "suspended" }>) {
329
+ console.log(JSON.stringify({ runId: result.runId, suspendedAt: result.suspendedAt }, null, 2))
330
+ }
331
+
332
+ function updateStoredRunState(result: RunSpecResult, descriptor: StoredRunDescriptor, ephemeral: boolean) {
333
+ if (ephemeral || result.runId === -1) return
334
+ if (result.status === "suspended") storeRunDescriptor(result.runId, descriptor)
335
+ else deleteRunDescriptor(result.runId)
336
+ }
337
+
338
+ async function resumeWithRuntime(
339
+ runtime: ToistRuntime,
340
+ runId: number,
341
+ task: CliTaskRef,
342
+ descriptor: StoredRunDescriptor,
343
+ response: unknown,
344
+ ): Promise<RunSpecResult> {
345
+ if (isResumableRuntime(runtime)) {
346
+ return await runtime.resumeRun(runId, {
347
+ token: task.responseToken,
348
+ response,
349
+ respondedBy: "cli",
350
+ })
351
+ }
352
+
353
+ return await resumeLocalStoredRun(runtime, runId, descriptor, {
354
+ taskId: task.id,
355
+ token: task.responseToken,
356
+ response,
357
+ respondedBy: "cli",
358
+ })
359
+ }
360
+
361
+ async function runLocalCommand(opts: RunCliOptions) {
362
+ if (opts.specStdin && opts.payloadStdin) {
363
+ console.error("--spec-stdin and --payload-stdin cannot both read from stdin")
364
+ process.exit(1)
365
+ }
366
+
367
+ const runtime = await getCliRuntime({ ephemeral: opts.ephemeral })
368
+ try {
369
+ const [spec, payload] = await Promise.all([loadRunSpec(opts), loadRunPayload(opts)])
370
+ const descriptor: StoredRunDescriptor = { spec, payload }
371
+ let result = await runSpec(spec, payload, {
372
+ runtime,
373
+ events: opts.suspendAsJson ? false : true,
374
+ })
375
+
376
+ while (true) {
377
+ updateStoredRunState(result, descriptor, opts.ephemeral)
378
+ if (!opts.suspendAsJson) await printRunEvents(result)
379
+
380
+ if (result.status === "suspended") {
381
+ if (opts.suspendAsJson) {
382
+ printSuspendAsJson(result)
383
+ return
384
+ }
385
+
386
+ const task = result.task
387
+ ? toCliTaskFromResult(result.task, result.suspendedAt.nodeId)
388
+ : await findOpenTask(runtime, result.runId)
389
+ if (task?.kind === "human.input" && canPromptInline(opts)) {
390
+ const response = await promptTaskResponse(task)
391
+ result = await resumeWithRuntime(runtime, result.runId, task, descriptor, response)
392
+ continue
393
+ }
394
+ }
395
+
396
+ printFinalResult(result)
397
+ if (result.status === "error") process.exit(1)
398
+ return
399
+ }
400
+ } catch (error) {
401
+ console.error((error as Error).message)
402
+ process.exit(1)
403
+ } finally {
404
+ await runtime.close?.()
405
+ }
406
+ }
407
+
408
+ async function runRemoteCommand(opts: RunCliOptions) {
409
+ if (!opts.remote) throw new Error("--remote requires a base URL")
410
+
411
+ const [spec, payload] = await Promise.all([loadRunSpec(opts), loadRunPayload(opts)])
412
+ const client = createRunnerClient({ baseUrl: normalizeRemoteBaseUrl(opts.remote) })
413
+ const started = await client.pipelines.runSpec(spec, payload)
414
+
415
+ if (opts.suspendAsJson) {
416
+ const final = await waitForRemoteRun(client, started.id)
417
+ if (final.status === "suspended") {
418
+ console.log(JSON.stringify({ runId: final.id, suspendedAt: final.suspendedAt }, null, 2))
419
+ return
420
+ }
421
+ console.log(JSON.stringify(final, null, 2))
422
+ if (final.status === "error") process.exit(1)
423
+ return
424
+ }
425
+
426
+ const eventsPromise = printRemoteRunEvents(client, started.id)
427
+ const final = await waitForRemoteRun(client, started.id)
428
+ await eventsPromise
429
+ console.log(JSON.stringify(final, null, 2))
430
+ if (final.status === "error") process.exit(1)
431
+ }
432
+
433
+ async function runCommand(opts: RunCliOptions) {
434
+ if (opts.remote && opts.ephemeral) {
435
+ console.error("--remote and --ephemeral cannot be used together")
436
+ process.exit(1)
437
+ }
438
+
439
+ if (opts.remote) {
440
+ try {
441
+ await runRemoteCommand(opts)
442
+ } catch (error) {
443
+ console.error((error as Error).message)
444
+ process.exit(1)
445
+ }
446
+ return
447
+ }
448
+
449
+ await runLocalCommand(opts)
450
+ }
451
+
452
+ function parseResponseArg(input?: string): unknown {
453
+ if (!input) return undefined
454
+ try {
455
+ return JSON.parse(input)
456
+ } catch (err) {
457
+ throw new Error(`invalid JSON response: ${(err as Error).message}`)
458
+ }
459
+ }
460
+
461
+ async function resumeCommand(opts: ResumeCliOptions) {
462
+ if (opts.runId === undefined || Number.isNaN(opts.runId)) {
463
+ console.error("Usage: toist resume <runId> [--response '<json>'] [--suspend-as-json]")
464
+ process.exit(1)
465
+ }
466
+
467
+ const descriptor = loadRunDescriptor(opts.runId)
468
+ if (!descriptor) {
469
+ console.error(`No stored run descriptor for run ${opts.runId}. Was it started with --ephemeral?`)
470
+ process.exit(1)
471
+ }
472
+
473
+ const runtime = await getCliRuntime()
474
+ try {
475
+ let task = await findOpenTask(runtime, opts.runId)
476
+ if (!task) {
477
+ console.error(`run ${opts.runId} has no open task`)
478
+ process.exit(1)
479
+ }
480
+
481
+ let response = parseResponseArg(opts.responseArg)
482
+ if (response === undefined) {
483
+ if (task.kind !== "human.input") {
484
+ console.error("--response is required for non-human.input tasks")
485
+ process.exit(1)
486
+ }
487
+ response = await promptTaskResponse(task)
488
+ }
489
+
490
+ let result = await resumeWithRuntime(runtime, opts.runId, task, descriptor, response)
491
+ while (true) {
492
+ updateStoredRunState(result, descriptor, false)
493
+ if (!opts.suspendAsJson) await printRunEvents(result)
494
+
495
+ if (result.status === "suspended") {
496
+ if (opts.suspendAsJson) {
497
+ printSuspendAsJson(result)
498
+ return
499
+ }
500
+
501
+ task = result.task
502
+ ? toCliTaskFromResult(result.task, result.suspendedAt.nodeId)
503
+ : await findOpenTask(runtime, result.runId)
504
+ if (task?.kind === "human.input") {
505
+ const nextResponse = await promptTaskResponse(task)
506
+ result = await resumeWithRuntime(runtime, result.runId, task, descriptor, nextResponse)
507
+ continue
508
+ }
509
+ }
510
+
511
+ printFinalResult(result)
512
+ if (result.status === "error") process.exit(1)
513
+ return
514
+ }
515
+ } catch (error) {
516
+ console.error((error as Error).message)
517
+ process.exit(1)
518
+ } finally {
519
+ await runtime.close?.()
520
+ }
521
+ }
522
+
523
+ function formatAge(iso: string): string {
524
+ const delta = Math.max(0, Date.now() - new Date(iso).getTime())
525
+ const seconds = Math.floor(delta / 1000)
526
+ if (seconds < 60) return `${seconds}s`
527
+ const minutes = Math.floor(seconds / 60)
528
+ if (minutes < 60) return `${minutes}m`
529
+ const hours = Math.floor(minutes / 60)
530
+ if (hours < 24) return `${hours}h`
531
+ return `${Math.floor(hours / 24)}d`
532
+ }
533
+
534
+ function printTaskTable(rows: Array<{ id: number; run_id: number; node_id: string; kind: string; created_at: string }>) {
535
+ const headers = ["ID", "RUN", "NODE", "KIND", "AGE"]
536
+ const body = rows.map((row) => [String(row.id), String(row.run_id), row.node_id, row.kind, formatAge(row.created_at)])
537
+ const widths = headers.map((header, index) => Math.max(header.length, ...body.map((cols) => cols[index].length)))
538
+ console.log(headers.map((header, index) => header.padEnd(widths[index])).join(" "))
539
+ for (const cols of body) {
540
+ console.log(cols.map((value, index) => value.padEnd(widths[index])).join(" "))
541
+ }
542
+ }
543
+
544
+ async function listTasksCommand(opts: TasksCliOptions) {
545
+ if (opts.remote) {
546
+ const client = createRunnerClient({ baseUrl: normalizeRemoteBaseUrl(opts.remote) })
547
+ const tasks = await client.tasks.list({ status: "open" })
548
+ if (opts.format === "json") console.log(JSON.stringify(tasks, null, 2))
549
+ else printTaskTable(tasks)
550
+ return
551
+ }
552
+
553
+ const runtime = await getCliRuntime()
554
+ try {
555
+ const tasks = await runtime.tasks.list({ status: "open" })
556
+ if (opts.format === "json") console.log(JSON.stringify(tasks, null, 2))
557
+ else printTaskTable(tasks)
558
+ } finally {
559
+ await runtime.close?.()
560
+ }
561
+ }
562
+
563
+ async function answerTaskCommand(taskId: number, responseArg?: string, remote?: string) {
564
+ if (Number.isNaN(taskId)) {
565
+ console.error("Usage: toist tasks answer <id> [--response '<json>'] [--remote <url>]")
566
+ process.exit(1)
567
+ }
568
+
569
+ if (remote) {
570
+ const client = createRunnerClient({ baseUrl: normalizeRemoteBaseUrl(remote) })
571
+ const task = await client.tasks.get(taskId)
572
+ const response = parseResponseArg(responseArg) ?? (task.kind === "human.input"
573
+ ? await promptTaskResponse(toCliTask(task))
574
+ : (() => { throw new Error("--response is required for non-human.input tasks") })())
575
+ const result = await client.tasks.respond(task.runId, task.id, {
576
+ token: task.responseToken,
577
+ response,
578
+ respondedBy: "cli",
579
+ })
580
+ console.log(JSON.stringify(result, null, 2))
581
+ return
582
+ }
583
+
584
+ const runtime = await getCliRuntime()
585
+ try {
586
+ let task = await runtime.tasks.get(taskId)
587
+ if (!task) {
588
+ console.error(`task ${taskId} not found`)
589
+ process.exit(1)
590
+ }
591
+
592
+ const descriptor = loadRunDescriptor(task.runId)
593
+ if (!descriptor) {
594
+ console.error(`No stored run descriptor for run ${task.runId}`)
595
+ process.exit(1)
596
+ }
597
+
598
+ let response = parseResponseArg(responseArg)
599
+ if (response === undefined) {
600
+ if (task.kind !== "human.input") {
601
+ console.error("--response is required for non-human.input tasks")
602
+ process.exit(1)
603
+ }
604
+ response = await promptTaskResponse(toCliTask(task))
605
+ }
606
+
607
+ let result = await resumeLocalStoredRun(runtime, task.runId, descriptor, {
608
+ taskId: task.id,
609
+ token: task.responseToken,
610
+ response,
611
+ respondedBy: "cli",
612
+ })
613
+
614
+ while (true) {
615
+ updateStoredRunState(result, descriptor, false)
616
+ await printRunEvents(result)
617
+ if (result.status === "suspended") {
618
+ task = result.task
619
+ ? { ...task, ...toCliTaskFromResult(result.task, result.suspendedAt.nodeId) }
620
+ : await runtime.tasks.get(taskId)
621
+ if (task?.kind === "human.input" && responseArg === undefined) {
622
+ const nextResponse = await promptTaskResponse(toCliTask(task))
623
+ result = await resumeLocalStoredRun(runtime, task.runId, descriptor, {
624
+ taskId: task.id,
625
+ token: task.responseToken,
626
+ response: nextResponse,
627
+ respondedBy: "cli",
628
+ })
629
+ continue
630
+ }
631
+ }
632
+ printFinalResult(result)
633
+ if (result.status === "error") process.exit(1)
634
+ return
635
+ }
636
+ } catch (error) {
637
+ console.error((error as Error).message)
638
+ process.exit(1)
639
+ } finally {
640
+ await runtime.close?.()
641
+ }
642
+ }
643
+
644
+ async function serveCommand(opts: ServeCliOptions) {
645
+ const runner = await startRunner({
646
+ rootDir: opts.rootDir,
647
+ port: opts.port,
648
+ storage: opts.memory ? "memory" : "sqlite",
649
+ disableUi: opts.noUi,
650
+ disableWatch: opts.noWatch,
651
+ })
652
+
653
+ const stop = async () => {
654
+ process.off("SIGINT", stop)
655
+ process.off("SIGTERM", stop)
656
+ await runner.stop()
657
+ process.exit(0)
658
+ }
659
+
660
+ process.on("SIGINT", stop)
661
+ process.on("SIGTERM", stop)
662
+ await new Promise(() => {})
663
+ }
664
+
665
+ async function validateFiles(pathArgs: string[]) {
666
+ if (pathArgs.length === 0) {
667
+ console.error("Usage: toist validate <file...>")
668
+ process.exit(1)
669
+ }
670
+
671
+ let ok = true
672
+ for (const pathArg of pathArgs) {
673
+ try {
674
+ const { path, parsed } = readPipelineFile(pathArg)
675
+ const result = validateSpec(parsed)
676
+
677
+ if (!result.ok) {
678
+ ok = false
679
+ console.error(`✗ ${path}`)
680
+ for (const error of result.errors) console.error(` - ${error}`)
681
+ continue
682
+ }
683
+
684
+ console.log(`✓ ${path}`)
685
+ for (const warning of result.warnings ?? []) console.log(` warning: ${warning}`)
686
+ } catch (error) {
687
+ ok = false
688
+ console.error(`✗ ${pathArg}`)
689
+ console.error(` - ${(error as Error).message}`)
690
+ }
691
+ }
692
+
693
+ if (!ok) process.exit(1)
694
+ }
695
+
696
+ async function planFile(pathArg?: string, format = "ascii") {
697
+ if (!pathArg) {
698
+ console.error("Usage: toist plan <file> [--format ascii|dot]")
699
+ process.exit(1)
700
+ }
701
+
702
+ if (format !== "ascii" && format !== "dot") {
703
+ console.error(`Unknown format: ${format}`)
704
+ process.exit(1)
705
+ }
706
+
707
+ try {
708
+ const { path, parsed } = readPipelineFile(pathArg)
709
+ const spec = requireParsedSpec(parsed, path)
710
+ console.log(format === "dot" ? renderDot(spec) : renderAscii(spec))
711
+ } catch (error) {
712
+ console.error((error as Error).message)
713
+ process.exit(1)
714
+ }
715
+ }
716
+
717
+ function renderDot(spec: PipelineSpec): string {
718
+ const lines = [`digraph \"${spec.id}\" {`, " rankdir=LR;"]
719
+ const seenEdges = new Set<string>()
720
+ for (const node of spec.parsedNodes) lines.push(` \"${node.id}\";`)
721
+ for (const edge of spec.edges) {
722
+ const key = `${edge.from}->${edge.to}`
723
+ if (seenEdges.has(key)) continue
724
+ seenEdges.add(key)
725
+ lines.push(` \"${edge.from}\" -> \"${edge.to}\";`)
726
+ }
727
+ lines.push("}")
728
+ return lines.join("\n")
729
+ }
730
+
731
+ function renderAscii(spec: PipelineSpec): string {
732
+ const children = new Map<string, string[]>()
733
+ const incoming = new Map<string, number>()
734
+
735
+ for (const node of spec.parsedNodes) {
736
+ children.set(node.id, [])
737
+ incoming.set(node.id, 0)
738
+ }
739
+
740
+ const seenEdges = new Set<string>()
741
+ for (const edge of spec.edges) {
742
+ const key = `${edge.from}->${edge.to}`
743
+ if (seenEdges.has(key)) continue
744
+ seenEdges.add(key)
745
+ children.get(edge.from)?.push(edge.to)
746
+ incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1)
747
+ }
748
+
749
+ const roots = spec.parsedNodes
750
+ .map((node) => node.id)
751
+ .filter((id) => (incoming.get(id) ?? 0) === 0)
752
+
753
+ const lines: string[] = []
754
+
755
+ const walk = (id: string, prefix: string, isLast: boolean, trail: Set<string>, depth: number) => {
756
+ const branch = depth === 0 ? "" : `${prefix}${isLast ? "└─ " : "├─ "}`
757
+ lines.push(`${branch}${id}`)
758
+
759
+ const nextPrefix = depth === 0 ? "" : `${prefix}${isLast ? " " : "│ "}`
760
+ const nextChildren = children.get(id) ?? []
761
+ for (let i = 0; i < nextChildren.length; i++) {
762
+ const child = nextChildren[i]
763
+ if (trail.has(child)) {
764
+ lines.push(`${nextPrefix}${i === nextChildren.length - 1 ? "└─ " : "├─ "}${child} ↺`)
765
+ continue
766
+ }
767
+ walk(child, nextPrefix, i === nextChildren.length - 1, new Set([...trail, child]), depth + 1)
768
+ }
769
+ }
770
+
771
+ for (let i = 0; i < roots.length; i++) {
772
+ if (i > 0) lines.push("")
773
+ walk(roots[i], "", true, new Set([roots[i]]), 0)
774
+ }
775
+
776
+ return lines.join("\n")
777
+ }
778
+
779
+ if (!cmd) {
780
+ await wizard()
781
+ } else if (cmd === "help" || cmd === "--help" || cmd === "-h") {
782
+ help()
783
+ } else if (cmd === "init") {
784
+ await wizard(process.cwd(), args[1])
785
+ } else if (cmd === "upgrade") {
786
+ await upgrade()
787
+ } else if (cmd === "run") {
788
+ const opts: RunCliOptions = {
789
+ specStdin: false,
790
+ payloadStdin: false,
791
+ suspendAsJson: false,
792
+ ephemeral: false,
793
+ }
794
+
795
+ const positional: string[] = []
796
+ for (let i = 1; i < args.length; i++) {
797
+ const arg = args[i]
798
+ if (arg === "--payload" || arg === "-p") opts.payloadArg = args[++i]
799
+ else if (arg.startsWith("--payload=")) opts.payloadArg = arg.slice("--payload=".length)
800
+ else if (arg === "--remote") opts.remote = args[++i]
801
+ else if (arg.startsWith("--remote=")) opts.remote = arg.slice("--remote=".length)
802
+ else if (arg === "--spec-stdin") opts.specStdin = true
803
+ else if (arg === "--payload-stdin") opts.payloadStdin = true
804
+ else if (arg === "--suspend-as-json") opts.suspendAsJson = true
805
+ else if (arg === "--ephemeral") opts.ephemeral = true
806
+ else positional.push(arg)
807
+ }
808
+
809
+ opts.pathArg = positional[0]
810
+ await runCommand(opts)
811
+ } else if (cmd === "resume") {
812
+ const opts: ResumeCliOptions = { suspendAsJson: false }
813
+ const positional: string[] = []
814
+ for (let i = 1; i < args.length; i++) {
815
+ const arg = args[i]
816
+ if (arg === "--response") opts.responseArg = args[++i]
817
+ else if (arg.startsWith("--response=")) opts.responseArg = arg.slice("--response=".length)
818
+ else if (arg === "--suspend-as-json") opts.suspendAsJson = true
819
+ else positional.push(arg)
820
+ }
821
+ opts.runId = positional[0] ? Number(positional[0]) : undefined
822
+ await resumeCommand(opts)
823
+ } else if (cmd === "serve") {
824
+ const opts: ServeCliOptions = {
825
+ port: 5172,
826
+ rootDir: process.cwd(),
827
+ memory: false,
828
+ noUi: false,
829
+ noWatch: false,
830
+ }
831
+ for (let i = 1; i < args.length; i++) {
832
+ const arg = args[i]
833
+ if (arg === "--port") opts.port = Number(args[++i] ?? opts.port)
834
+ else if (arg.startsWith("--port=")) opts.port = Number(arg.slice("--port=".length))
835
+ else if (arg === "--rootDir") opts.rootDir = resolve(process.cwd(), args[++i] ?? ".")
836
+ else if (arg.startsWith("--rootDir=")) opts.rootDir = resolve(process.cwd(), arg.slice("--rootDir=".length))
837
+ else if (arg === "--memory") opts.memory = true
838
+ else if (arg === "--no-ui") opts.noUi = true
839
+ else if (arg === "--no-watch") opts.noWatch = true
840
+ }
841
+ await serveCommand(opts)
842
+ } else if (cmd === "tasks") {
843
+ const sub = args[1]
844
+ if (sub === "list") {
845
+ const opts: TasksCliOptions = { format: "table" }
846
+ for (let i = 2; i < args.length; i++) {
847
+ const arg = args[i]
848
+ if (arg === "--remote") opts.remote = args[++i]
849
+ else if (arg.startsWith("--remote=")) opts.remote = arg.slice("--remote=".length)
850
+ else if (arg === "--format") opts.format = (args[++i] as "table" | "json") ?? "table"
851
+ else if (arg.startsWith("--format=")) opts.format = arg.slice("--format=".length) as "table" | "json"
852
+ }
853
+ await listTasksCommand(opts)
854
+ } else if (sub === "answer") {
855
+ let responseArg: string | undefined
856
+ let remote: string | undefined
857
+ for (let i = 3; i < args.length; i++) {
858
+ const arg = args[i]
859
+ if (arg === "--response") responseArg = args[++i]
860
+ else if (arg.startsWith("--response=")) responseArg = arg.slice("--response=".length)
861
+ else if (arg === "--remote") remote = args[++i]
862
+ else if (arg.startsWith("--remote=")) remote = arg.slice("--remote=".length)
863
+ }
864
+ await answerTaskCommand(Number(args[2]), responseArg, remote)
865
+ } else {
866
+ console.error("Usage: toist tasks <list|answer>")
867
+ process.exit(1)
868
+ }
869
+ } else if (cmd === "validate") {
870
+ await validateFiles(args.slice(1))
871
+ } else if (cmd === "plan") {
872
+ let format = "ascii"
873
+ for (let i = 2; i < args.length; i++) {
874
+ if (args[i] === "--format") format = args[++i] ?? format
875
+ else if (args[i].startsWith("--format=")) format = args[i].slice("--format=".length)
876
+ }
877
+ await planFile(args[1], format)
878
+ } else if (cmd === ".toist" || cmd.startsWith("./") || cmd.startsWith("../") || cmd.startsWith("/")) {
879
+ await wizard(process.cwd(), cmd)
880
+ } else {
881
+ console.error(`Unknown command: ${cmd}`)
882
+ console.error("")
883
+ help()
884
+ process.exit(1)
885
+ }