@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/CHANGELOG.md +40 -0
- package/README.md +42 -24
- package/package.json +10 -3
- package/src/cli.ts +885 -0
- package/src/index.ts +1 -36
- package/src/runtime.ts +75 -0
- package/src/wizard.ts +67 -120
- package/templates/default/README.md +35 -0
- package/templates/default/_gitignore +7 -0
- package/templates/default/_package.json +13 -0
- package/templates/default/data/.gitkeep +0 -0
- package/templates/default/kinds/custom.ts +23 -0
- package/templates/default/pipelines/.gitkeep +0 -0
- package/templates/default/resources/.gitkeep +0 -0
- package/templates/default/start.ts +14 -0
- package/templates/default/toist.yml +2 -0
- package/templates/embedded/README.md +58 -0
- package/templates/embedded/_gitignore +5 -0
- package/templates/embedded/data/.gitkeep +0 -0
- package/templates/embedded/instance.json +4 -0
- package/templates/embedded/pipelines/.gitkeep +0 -0
- package/templates/embedded/resources/.gitkeep +0 -0
- package/templates/embedded/start.ts +24 -0
- package/templates/embedded/toist.yml +2 -0
- package/templates/embedded-memory/README.md +12 -0
- package/templates/embedded-memory/_gitignore +1 -0
- package/templates/embedded-memory/pipelines/hello.yml +12 -0
- package/templates/embedded-memory/start.ts +16 -0
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
|
+
}
|