syndai 0.3.0 → 0.4.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.
@@ -0,0 +1,377 @@
1
+ // `syndai schedule` (a project's recurring runs) and `syndai repeat` (repeat a run you liked).
2
+ //
3
+ // The server owns cron: it labels a cadence (`cadence_label`) and lists the next fires
4
+ // (`upcoming`), so nothing here parses cron. The only mapping is the presets the portal's
5
+ // Repeats field offers (apps/portal-v2/src/lib/cadence.ts `toCron`), with Sunday = 0.
6
+ // Merge and skip policy are edited in the portal or with `syndai api updateSchedule`.
7
+
8
+ import { ApiError, CliError, EXIT } from "./errors.js";
9
+ import { projectLabel, resolveProject } from "./projects.js";
10
+ import { ask, ulid } from "./ui.js";
11
+
12
+ const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
13
+ const MAX_PAGES = 50;
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const PAUSE_REASONS = {
16
+ consecutive_failures: "it failed 5 times in a row",
17
+ invalid_trigger: "its schedule is no longer valid",
18
+ };
19
+ const CADENCE_HELP = 'hourly --at :MM, daily|weekdays --at HH:MM, weekly --at "mon HH:MM", or a 5-field cron';
20
+
21
+ // --- cadence -------------------------------------------------------------------------------
22
+
23
+ /** The IANA zone to use: --tz (checked), else this computer's. */
24
+ export function timeZoneOf(flags) {
25
+ const zone = flags.tz ?? (Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
26
+ try {
27
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
28
+ } catch {
29
+ throw new CliError(EXIT.USAGE, `Unknown time zone "${zone}"; use an IANA name like Europe/Berlin.`);
30
+ }
31
+ return zone;
32
+ }
33
+
34
+ /** Wall-clock hour, minute, second and weekday (0 = Sunday) of `now` in `zone`. */
35
+ function clock(now, zone) {
36
+ const parts = Object.fromEntries(
37
+ new Intl.DateTimeFormat("en-US", {
38
+ timeZone: zone,
39
+ hourCycle: "h23",
40
+ hour: "2-digit",
41
+ minute: "2-digit",
42
+ second: "2-digit",
43
+ weekday: "short",
44
+ })
45
+ .formatToParts(new Date(now))
46
+ .map((p) => [p.type, p.value]),
47
+ );
48
+ return {
49
+ hh: Number(parts.hour) % 24,
50
+ mm: Number(parts.minute),
51
+ ss: Number(parts.second),
52
+ dow: DAYS.indexOf(parts.weekday.slice(0, 3).toLowerCase()),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * `--every` (+ `--at`) -> a 5-field cron, as the portal's `toCron` writes it. `--at` defaults
58
+ * to the next quarter hour in the schedule's zone (the portal's `defaultCadence`).
59
+ */
60
+ export function cronFor({ every, at }, now, zone) {
61
+ const fail = (why) => new CliError(EXIT.USAGE, `${why} --every takes ${CADENCE_HELP}.`);
62
+ if (!every) throw fail("How often?");
63
+ const fields = every.trim().split(/\s+/);
64
+ if (fields.length === 5) {
65
+ if (at !== undefined) throw fail("--at does not go with a cron expression.");
66
+ return fields.join(" ");
67
+ }
68
+ const preset = every.trim().toLowerCase();
69
+ if (!["hourly", "daily", "weekdays", "weekly"].includes(preset)) throw fail(`"${every}" is not a cadence.`);
70
+
71
+ const c = clock(now, zone);
72
+ const minutes = (Math.ceil((c.hh * 60 + c.mm + (c.ss > 0 ? 1 : 0)) / 15) * 15) % 1440;
73
+ let hh = Math.floor(minutes / 60);
74
+ let mm = minutes % 60;
75
+ let dow = c.dow;
76
+ if (at !== undefined) {
77
+ const text = at.trim().toLowerCase();
78
+ let match;
79
+ if (preset === "hourly") {
80
+ match = /^:?(\d{1,2})$/.exec(text);
81
+ if (!match || Number(match[1]) > 59) throw fail(`--at for hourly is :MM, not "${at}".`);
82
+ mm = Number(match[1]);
83
+ } else {
84
+ const shape = preset === "weekly" ? /^([a-z]+|[0-6])\s+(\d{1,2}):(\d{2})$/ : /^()(\d{1,2}):(\d{2})$/;
85
+ match = shape.exec(text);
86
+ const day = match?.[1] ? dayIndex(match[1]) : dow;
87
+ if (!match || Number(match[2]) > 23 || Number(match[3]) > 59 || day < 0) {
88
+ throw fail(`--at for ${preset} is ${preset === "weekly" ? '"mon HH:MM"' : "HH:MM"}, not "${at}".`);
89
+ }
90
+ [hh, mm, dow] = [Number(match[2]), Number(match[3]), day];
91
+ }
92
+ }
93
+ return {
94
+ hourly: `${mm} * * * *`,
95
+ daily: `${mm} ${hh} * * *`,
96
+ weekdays: `${mm} ${hh} * * 1-5`,
97
+ weekly: `${mm} ${hh} * * ${dow}`,
98
+ }[preset];
99
+ }
100
+
101
+ function dayIndex(text) {
102
+ if (/^[0-6]$/.test(text)) return Number(text);
103
+ const short = text.slice(0, 3);
104
+ const full = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
105
+ const index = DAYS.indexOf(short);
106
+ return index >= 0 && full[index].startsWith(text) ? index : -1;
107
+ }
108
+
109
+ /** `--budget 5` -> 5; `--budget none` -> null (no per-run cap) where allowed. */
110
+ function budgetOf(text, { allowNone }) {
111
+ if (text === undefined) return undefined;
112
+ if (allowNone && text.trim().toLowerCase() === "none") return null;
113
+ const value = Number(text.replace(/^\$/, ""));
114
+ if (!Number.isFinite(value) || value <= 0) {
115
+ throw new CliError(EXIT.USAGE, `--budget is a dollar amount per run${allowNone ? ", or none" : ""}, not "${text}".`);
116
+ }
117
+ return value;
118
+ }
119
+
120
+ function triggerOf(ctx, { every, at, tz }) {
121
+ const zone = timeZoneOf({ tz });
122
+ if (tz === undefined && zone === "UTC" && ctx.ui.interactive) {
123
+ ctx.ui.progress(ctx.ui.dim("(your system time zone is UTC; pass --tz to change)"));
124
+ }
125
+ return { kind: "cron", expr: cronFor({ every, at }, ctx.io.now(), zone), timezone: zone };
126
+ }
127
+
128
+ // --- printing ------------------------------------------------------------------------------
129
+
130
+ function when(iso, zone) {
131
+ if (!iso) return "-";
132
+ return new Intl.DateTimeFormat("en-US", {
133
+ timeZone: zone,
134
+ weekday: "short",
135
+ month: "short",
136
+ day: "numeric",
137
+ hour: "numeric",
138
+ minute: "2-digit",
139
+ }).format(new Date(iso));
140
+ }
141
+
142
+ const labelOf = (s) => `${s.cadence_label ?? s.trigger.expr} ${s.trigger.timezone}`;
143
+ const firstLine = (text) => (text ?? "").split("\n")[0].slice(0, 60);
144
+
145
+ function stateOf(s) {
146
+ if (s.status !== "paused") return `next ${when(s.upcoming?.[0] ?? s.next_fire_at, s.trigger.timezone)}`;
147
+ const why = s.pause_reason && s.pause_reason !== "user" ? (PAUSE_REASONS[s.pause_reason] ?? s.pause_reason) : null;
148
+ return why ? `paused (${why})` : "paused";
149
+ }
150
+
151
+ function row(s) {
152
+ const last = s.last_occurrence ? (s.last_occurrence.summary ?? s.last_occurrence.disposition) : "";
153
+ return `${s.id.slice(0, 8)} ${labelOf(s)} ${stateOf(s)} "${firstLine(s.prompt)}"${last ? ` ${last}` : ""}`;
154
+ }
155
+
156
+ function upcomingLine(s) {
157
+ const times = (s.upcoming ?? []).map((t) => when(t, s.trigger.timezone));
158
+ return times.length ? `Next: ${times.join(" · ")}` : "Next: -";
159
+ }
160
+
161
+ function card(s, occurrences = []) {
162
+ const lines = [`${labelOf(s)} · ${stateOf(s)}`, `"${firstLine(s.prompt)}"`];
163
+ if (s.repo_name) lines.push(`Project ${s.repo_name}`);
164
+ if (s.occurrence_budget_usd !== null && s.occurrence_budget_usd !== undefined) {
165
+ lines.push(`Cap $${s.occurrence_budget_usd} per run`);
166
+ }
167
+ if (s.status !== "paused") lines.push(upcomingLine(s));
168
+ lines.push("", occurrences.length ? "History:" : "History: none yet");
169
+ for (const o of occurrences) {
170
+ const parts = [when(o.slot_ts, s.trigger.timezone), o.disposition, o.summary, o.coding_run_id].filter(Boolean);
171
+ lines.push(` ${parts.join(" · ")}`);
172
+ }
173
+ return lines.join("\n");
174
+ }
175
+
176
+ // --- API -----------------------------------------------------------------------------------
177
+
178
+ async function listAll(ctx, query) {
179
+ const schedules = [];
180
+ let cursor;
181
+ for (let page = 0; page < MAX_PAGES; page++) {
182
+ const res = await ctx.client.call("listSchedules", { query: { ...query, limit: 100, cursor } });
183
+ schedules.push(...res.schedules);
184
+ cursor = res.next_cursor;
185
+ if (!cursor) break;
186
+ }
187
+ return schedules;
188
+ }
189
+
190
+ /** A full id, or the 8-character prefix `schedule list` prints. */
191
+ async function scheduleId(ctx, ref) {
192
+ if (!ref) throw new CliError(EXIT.USAGE, "Which schedule? Its id is in `syndai schedule list`.");
193
+ if (UUID.test(ref)) return ref;
194
+ const hits = (await listAll(ctx, {})).filter((s) => s.id.startsWith(ref.toLowerCase()));
195
+ if (hits.length === 1) return hits[0].id;
196
+ throw new CliError(EXIT.USAGE, hits.length ? `"${ref}" matches ${hits.length} schedules; use more of the id.` : `No schedule "${ref}".`, {
197
+ next: "syndai schedule list --all",
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Create with a fresh request id (so a retried POST is the same create). A replay answers
203
+ * 409 + Location: that schedule is the result. A 409 without one is a changed replay.
204
+ */
205
+ async function create(ctx, body) {
206
+ const res = await ctx.client.call("createSchedule", {
207
+ body: { ...body, client_request_id: `cli:${ulid()}` },
208
+ retry: true,
209
+ raw: true,
210
+ });
211
+ if (res.status < 300) return res.body;
212
+ const location = res.status === 409 ? res.headers.get("location") : null;
213
+ if (location) return (await ctx.client.send("GET", new URL(location, ctx.baseUrl).pathname)).schedule;
214
+ throw new ApiError(res.status, res.body, res.headers);
215
+ }
216
+
217
+ function printCreated(ctx, s, heading) {
218
+ ctx.ui.result(
219
+ s,
220
+ [heading, `${labelOf(s)}: "${firstLine(s.prompt)}"`, upcomingLine(s), `Manage: syndai schedule show ${s.id}`].join("\n"),
221
+ );
222
+ return EXIT.OK;
223
+ }
224
+
225
+ // --- commands ------------------------------------------------------------------------------
226
+
227
+ export async function schedule(ctx, [action, ...rest]) {
228
+ const handler = ACTIONS[action];
229
+ if (!handler) {
230
+ throw new CliError(EXIT.USAGE, "Usage: syndai schedule list|show|add|edit|pause|resume|run-now|delete", {
231
+ next: "syndai schedule list",
232
+ });
233
+ }
234
+ return handler(ctx, rest);
235
+ }
236
+
237
+ const ACTIONS = {
238
+ async list(ctx) {
239
+ const { flags, ui } = ctx;
240
+ if (flags.all && flags.app) throw new CliError(EXIT.USAGE, "Pick one of --app and --all.");
241
+ const project = flags.all ? null : await resolveProject(ctx, { app: flags.app });
242
+ const schedules = await listAll(ctx, project ? { coding_repository_id: project.id } : {});
243
+ const where = project ? projectLabel(project) : "any project";
244
+ ui.result(
245
+ { project_id: project?.id ?? null, schedules },
246
+ schedules.length
247
+ ? schedules.map(row).join("\n")
248
+ : `Nothing repeats on ${where} yet. Add one: syndai schedule add "<what>" --every daily`,
249
+ );
250
+ return EXIT.OK;
251
+ },
252
+
253
+ async show(ctx, [ref]) {
254
+ const id = await scheduleId(ctx, ref);
255
+ const detail = await ctx.client.call("getSchedule", { params: { schedule_id: id } });
256
+ ctx.ui.result(detail, card(detail.schedule, detail.occurrences));
257
+ return EXIT.OK;
258
+ },
259
+
260
+ async add(ctx, words) {
261
+ const { flags } = ctx;
262
+ const prompt = words.join(" ").trim();
263
+ if (!prompt) throw new CliError(EXIT.USAGE, 'Say what to run: syndai schedule add "<what>" --every daily');
264
+ const trigger = triggerOf(ctx, flags);
265
+ const budget = budgetOf(flags.budget, { allowNone: false });
266
+ const project = await resolveProject(ctx, { app: flags.app });
267
+ const created = await create(ctx, {
268
+ prompt,
269
+ coding_repository_id: project.id,
270
+ trigger,
271
+ ...(budget === undefined ? {} : { occurrence_budget_usd: budget }),
272
+ });
273
+ return printCreated(ctx, created, `Repeats on ${projectLabel(project)}.`);
274
+ },
275
+
276
+ async edit(ctx, [ref]) {
277
+ const { client, flags, ui } = ctx;
278
+ const id = await scheduleId(ctx, ref);
279
+ if ([flags.prompt, flags.every, flags.at, flags.tz, flags.budget].every((v) => v === undefined)) {
280
+ throw new CliError(EXIT.USAGE, "Nothing to change: pass --prompt, --every, --at, --tz or --budget.");
281
+ }
282
+ if (flags.at !== undefined && flags.every === undefined) {
283
+ throw new CliError(EXIT.USAGE, "--at goes with --every (the cadence it sets the time of).");
284
+ }
285
+ const { schedule: current } = await client.call("getSchedule", { params: { schedule_id: id } });
286
+ const patch = {};
287
+ if (flags.prompt !== undefined && flags.prompt !== current.prompt) patch.prompt = flags.prompt;
288
+ if (flags.every !== undefined || flags.tz !== undefined) {
289
+ const zone = timeZoneOf({ tz: flags.tz ?? current.trigger.timezone });
290
+ const expr = flags.every === undefined ? current.trigger.expr : cronFor(flags, ctx.io.now(), zone);
291
+ if (expr !== current.trigger.expr || zone !== current.trigger.timezone) {
292
+ patch.trigger = { kind: "cron", expr, timezone: zone };
293
+ }
294
+ }
295
+ const budget = budgetOf(flags.budget, { allowNone: true });
296
+ if (budget !== undefined && budget !== (current.occurrence_budget_usd ?? null)) {
297
+ patch.occurrence_budget_usd = budget;
298
+ }
299
+ if (Object.keys(patch).length === 0) {
300
+ ui.result(current, `Nothing changed. ${row(current)}`);
301
+ return EXIT.OK;
302
+ }
303
+ const updated = await client.call("updateSchedule", { params: { schedule_id: id }, body: patch });
304
+ ui.result(updated, `Updated. ${row(updated)}`);
305
+ return EXIT.OK;
306
+ },
307
+
308
+ async pause(ctx, [ref]) {
309
+ const id = await scheduleId(ctx, ref);
310
+ const s = await ctx.client.call("pauseSchedule", { params: { schedule_id: id } });
311
+ ctx.ui.result(s, `Paused. ${row(s)}`);
312
+ return EXIT.OK;
313
+ },
314
+
315
+ async resume(ctx, [ref]) {
316
+ const id = await scheduleId(ctx, ref);
317
+ const s = await ctx.client.call("resumeSchedule", { params: { schedule_id: id } });
318
+ ctx.ui.result(s, `Resumed. ${row(s)}`);
319
+ return EXIT.OK;
320
+ },
321
+
322
+ async "run-now"(ctx, [ref]) {
323
+ const id = await scheduleId(ctx, ref);
324
+ const res = await ctx.client.call("runScheduleNow", { params: { schedule_id: id }, raw: true });
325
+ if (res.status >= 400 && res.status !== 409) throw new ApiError(res.status, res.body, res.headers);
326
+ const already = res.status === 409;
327
+ ctx.ui.result(
328
+ { schedule_id: id, started: !already, already_started: already },
329
+ `${already ? "It already started this minute." : "Started."} Its run shows in: syndai schedule show ${id}`,
330
+ );
331
+ return EXIT.OK;
332
+ },
333
+
334
+ async delete(ctx, [ref]) {
335
+ const { flags, ui, io } = ctx;
336
+ const id = await scheduleId(ctx, ref);
337
+ if (!flags.yes) {
338
+ if (!ui.interactive) {
339
+ throw new CliError(EXIT.USAGE, "Deleting a schedule needs --yes when nobody can confirm it.", {
340
+ next: `syndai schedule delete ${id} --yes`,
341
+ });
342
+ }
343
+ const answer = await ask(io, `Delete schedule ${id.slice(0, 8)} and its history? [y/N] `);
344
+ if (!/^y/i.test(answer)) {
345
+ ui.result({ schedule_id: id, deleted: false }, "Kept.");
346
+ return EXIT.OK;
347
+ }
348
+ }
349
+ await ctx.client.call("deleteSchedule", { params: { schedule_id: id } });
350
+ ui.result({ schedule_id: id, deleted: true }, "Deleted.");
351
+ return EXIT.OK;
352
+ },
353
+ };
354
+
355
+ /**
356
+ * Repeat a run: the server copies its original prompt and project (`from_coding_run_id`).
357
+ * The first run is the next scheduled time, never now: re-running a run that just finished
358
+ * would charge twice.
359
+ */
360
+ export async function repeat(ctx, [runId]) {
361
+ const { client, flags } = ctx;
362
+ if (!runId) throw new CliError(EXIT.USAGE, "Which run? syndai repeat <run> --every weekly");
363
+ const trigger = triggerOf(ctx, flags);
364
+ const budget = budgetOf(flags.budget, { allowNone: false });
365
+ const { run } = await client.call("getRun", { params: { coding_run_id: runId } });
366
+ const created = await create(ctx, {
367
+ from_coding_run_id: runId,
368
+ trigger,
369
+ ...(budget === undefined ? {} : { occurrence_budget_usd: budget }),
370
+ });
371
+ const cost = typeof run.cost_usd === "number" ? ` (last run cost $${run.cost_usd.toFixed(2)})` : "";
372
+ return printCreated(
373
+ ctx,
374
+ created,
375
+ `Repeating "${firstLine(run.title ?? run.prompt)}"${cost}. First run ${when(created.upcoming?.[0], created.trigger.timezone)}.`,
376
+ );
377
+ }
package/src/settings.js CHANGED
@@ -1,4 +1,4 @@
1
- // `syndai settings` (one project's owner settings) and `syndai api` (the escape hatch).
1
+ // `syndai settings`: one project's owner settings.
2
2
  //
3
3
  // Settings live in four places server-side:
4
4
  // autonomy repo-scoped policy default GET/PUT policy/defaults
@@ -128,33 +128,3 @@ async function orNull(promise) {
128
128
  throw err;
129
129
  }
130
130
  }
131
-
132
- export async function api(ctx, [method, target]) {
133
- const { client, flags, io, ui } = ctx;
134
- const verb = method?.toUpperCase();
135
- if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(verb) || !target) {
136
- throw new CliError(EXIT.USAGE, "Usage: syndai api <METHOD> <path> [--json body]", {
137
- next: "syndai api GET /runs",
138
- });
139
- }
140
- const full = target.startsWith("/api/") ? target : `/api/v1/external/coding/${target.replace(/^\/+/, "")}`;
141
- const [pathOnly, search = ""] = full.split("?");
142
- let body;
143
- if (typeof flags.json === "string") {
144
- try {
145
- body = JSON.parse(flags.json);
146
- } catch {
147
- throw new CliError(EXIT.USAGE, "--json must be valid JSON.");
148
- }
149
- }
150
- const res = await client.send(verb, pathOnly, {
151
- query: Object.fromEntries(new URLSearchParams(search)),
152
- body,
153
- raw: true,
154
- });
155
- io.stdout.write(`${JSON.stringify(res.body, null, ui.json ? 0 : 2)}\n`);
156
- if (res.status < 400) return EXIT.OK;
157
- const { exitCode, message } = explain(new ApiError(res.status, res.body, res.headers));
158
- ui.progress(`error: ${message}`);
159
- return exitCode;
160
- }
package/src/store.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // $XDG_CONFIG_HOME/syndai/credentials.json keys, by workspace (customer), mode 0600
3
3
  // $XDG_CONFIG_HOME/syndai/links.json directory -> project
4
4
  // $XDG_STATE_HOME/syndai/pending/*.json a change's request id, written BEFORE its POST
5
+ // $XDG_CACHE_HOME/syndai/openapi-<host>.json the live API description (24h)
5
6
 
6
7
  import fs from "node:fs";
7
8
  import { homedir } from "node:os";
@@ -14,11 +15,13 @@ export function storePaths(env) {
14
15
  const home = env.HOME || homedir();
15
16
  const config = path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "syndai");
16
17
  const state = path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "syndai");
18
+ const cache = path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "syndai");
17
19
  return {
18
20
  home,
19
21
  credentials: path.join(config, "credentials.json"),
20
22
  links: path.join(config, "links.json"),
21
23
  pending: path.join(state, "pending"),
24
+ cache,
22
25
  };
23
26
  }
24
27
 
package/src/ui.js CHANGED
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { spawn } from "node:child_process";
7
7
  import { getRandomValues } from "node:crypto";
8
+ import { readFileSync } from "node:fs";
9
+ import path from "node:path";
8
10
  import { createInterface } from "node:readline";
9
11
  import { CliError, EXIT } from "./errors.js";
10
12
  import { interrupted } from "./http.js";
@@ -131,3 +133,24 @@ export function hostOf(url) {
131
133
  return null;
132
134
  }
133
135
  }
136
+
137
+ export async function readAll(stream) {
138
+ let text = "";
139
+ for await (const chunk of stream) text += chunk;
140
+ return text;
141
+ }
142
+
143
+ /** `--input FILE|-` as parsed JSON (`-` reads stdin). */
144
+ export async function readJsonInput(io, source) {
145
+ let text;
146
+ try {
147
+ text = source === "-" ? await readAll(io.stdin) : readFileSync(path.resolve(io.cwd, source), "utf8");
148
+ } catch (err) {
149
+ throw new CliError(EXIT.USAGE, `Cannot read --input ${source} (${err.code ?? err.message}).`);
150
+ }
151
+ try {
152
+ return JSON.parse(text);
153
+ } catch {
154
+ throw new CliError(EXIT.USAGE, `--input ${source} is not valid JSON.`);
155
+ }
156
+ }
package/src/watch.js CHANGED
@@ -14,7 +14,7 @@ const MIN_POLL_GAP_MS = 1000;
14
14
  const DEADLINE_GRACE_MS = 2000;
15
15
  const TIMED_OUT = Symbol("timed out");
16
16
 
17
- // Exit code per ChangeState state (docs: cli.mdx "Exit codes").
17
+ // Exit code per ChangeState state (docs: syndai.ai/docs/cli, "Exit codes").
18
18
  const STATE_EXIT = {
19
19
  needs_input: EXIT.NEEDS_YOU,
20
20
  held: EXIT.NEEDS_YOU,
@@ -202,11 +202,18 @@ async function askInput(ctx, input) {
202
202
  }
203
203
  }
204
204
  ui.progress(input.prompt);
205
- for (const [i, option] of options.entries()) ui.progress(` ${i + 1}. ${option.label}`);
205
+ const defaults = new Set(input.default_option_ids ?? []);
206
+ for (const [i, option] of options.entries()) {
207
+ ui.progress(` ${i + 1}. ${defaults.has(option.id) ? "[x] " : ""}${option.label}`);
208
+ }
206
209
  const multi = input.kind === "multi_choice";
207
210
  const hint = multi ? "numbers, comma-separated" : "a number";
211
+ const keep = defaults.size ? ", Enter keeps [x]" : "";
208
212
  for (;;) {
209
- const answer = await ask(io, `Pick ${hint}${textAllowed ? " or type an answer" : ""}${optional}: `);
213
+ const answer = await ask(io, `Pick ${hint}${textAllowed ? " or type an answer" : ""}${keep}${optional}: `);
214
+ if (!answer && defaults.size) {
215
+ return { input_id: input.input_id, selected_option_ids: options.filter((o) => defaults.has(o.id)).map((o) => o.id) };
216
+ }
210
217
  if (!answer && !input.required) return null;
211
218
  const picks = answer.split(",").map((part) => Number(part.trim()) - 1);
212
219
  const valid = picks.every((i) => Number.isInteger(i) && options[i]) && (multi || picks.length === 1);