@black942026/pi-ask-user 0.1.1 → 0.1.2

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/schema.ts CHANGED
@@ -1,8 +1,16 @@
1
1
  import { Type } from "typebox";
2
- import type { AskUserOption, AskUserQuestion, NormalizedQuestion, NormalizedRequest, QuestionKind } from "./types.ts";
2
+ import type {
3
+ AskUserOption,
4
+ FormFieldKind,
5
+ NormalizedFormField,
6
+ NormalizedQuestion,
7
+ NormalizedRequest,
8
+ QuestionKind,
9
+ } from "./types.ts";
3
10
 
4
11
  export const MAX_QUESTIONS = 5;
5
12
  export const MAX_OPTIONS = 5;
13
+ export const MAX_FORM_FIELDS = 10;
6
14
  export const DEFAULT_TIMEOUT_PER_QUESTION_MS = 60_000;
7
15
 
8
16
  export const LIMITS = {
@@ -13,18 +21,20 @@ export const LIMITS = {
13
21
  preview: 4000,
14
22
  defaultValue: 500,
15
23
  header: 200,
24
+ fieldName: 64,
16
25
  } as const;
17
26
 
18
27
  const ID_MAX_LENGTH = 64;
19
- const QUESTION_KINDS = ["single", "multi", "input"] as const;
20
- const QUESTION_FIELDS = ["id", "title", "prompt", "kind", "options", "default"] as const;
28
+ const QUESTION_KINDS = ["single", "multi", "input", "bool", "simple-form"] as const;
29
+ const FIELD_KINDS = ["single", "multi", "input", "bool"] as const;
30
+ const QUESTION_FIELDS = ["id", "title", "prompt", "kind", "options", "default", "fields"] as const;
31
+ const FORM_FIELD_FIELDS = ["name", "label", "kind", "required", "options", "default"] as const;
21
32
  const OPTION_FIELDS = ["label", "description", "preview"] as const;
22
33
  const REQUEST_FIELDS = ["questions", "header", "displayMode", "timeoutPerQuestionMs"] as const;
34
+ const SAFE_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
35
+ const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
23
36
 
24
- /**
25
- * Flat string enum. Emitted as `{ type: "string", enum: [...] }` because some
26
- * provider proxies reject `anyOf`/`union` shapes.
27
- */
37
+ /** Flat string enum: provider proxies commonly reject unions used only as enums. */
28
38
  function StringEnum<T extends readonly string[]>(values: T, options: Record<string, unknown> = {}) {
29
39
  return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
30
40
  }
@@ -33,7 +43,25 @@ const OptionSchema = Type.Object({
33
43
  label: Type.String({ minLength: 1, maxLength: LIMITS.label }),
34
44
  description: Type.Optional(Type.String({ maxLength: LIMITS.description })),
35
45
  preview: Type.Optional(Type.String({ maxLength: LIMITS.preview })),
36
- });
46
+ }, { additionalProperties: false });
47
+
48
+ // Field defaults use three JSON types across the four field kinds. Keep that honest in
49
+ // the provider schema; kind-specific membership and type rules are enforced by
50
+ // the same direct normalizer used by tool execution.
51
+ const FieldDefaultSchema = Type.Union([
52
+ Type.Boolean(),
53
+ Type.String({ maxLength: LIMITS.defaultValue }),
54
+ Type.Array(Type.String({ minLength: 1, maxLength: LIMITS.label }), { maxItems: MAX_OPTIONS }),
55
+ ]);
56
+
57
+ const FormFieldSchema = Type.Object({
58
+ name: Type.String({ minLength: 1, maxLength: LIMITS.fieldName, pattern: SAFE_FIELD_NAME.source }),
59
+ label: Type.String({ minLength: 1, maxLength: LIMITS.label }),
60
+ kind: StringEnum(FIELD_KINDS),
61
+ required: Type.Optional(Type.Boolean()),
62
+ options: Type.Optional(Type.Array(OptionSchema, { maxItems: MAX_OPTIONS })),
63
+ default: Type.Optional(FieldDefaultSchema),
64
+ }, { additionalProperties: false });
37
65
 
38
66
  const QuestionSchema = Type.Object({
39
67
  id: Type.Optional(Type.String({ minLength: 1, maxLength: ID_MAX_LENGTH })),
@@ -41,27 +69,20 @@ const QuestionSchema = Type.Object({
41
69
  prompt: Type.Optional(Type.String({ maxLength: LIMITS.prompt })),
42
70
  kind: Type.Optional(StringEnum(QUESTION_KINDS)),
43
71
  options: Type.Optional(Type.Array(OptionSchema, { maxItems: MAX_OPTIONS })),
44
- default: Type.Optional(Type.String({ maxLength: LIMITS.defaultValue })),
45
- });
46
-
47
- /**
48
- * Parameter schema registered for the `ask_user` tool, and the single input
49
- * contract for direct `askUser` calls too. Only these fields and types are read;
50
- * anything else is rejected as `invalid_request` rather than coerced.
51
- *
52
- * The UI route is host configuration, resolved by the host adapter — it is never
53
- * part of a request.
54
- */
72
+ default: Type.Optional(Type.Union([Type.String({ maxLength: LIMITS.defaultValue }), Type.Boolean()])),
73
+ fields: Type.Optional(Type.Array(FormFieldSchema, { minItems: 1, maxItems: MAX_FORM_FIELDS })),
74
+ }, { additionalProperties: false });
75
+
76
+ /** Strict tool/direct-call parameter contract. */
55
77
  export const AskUserParams = Type.Object({
56
78
  questions: Type.Array(QuestionSchema, { minItems: 1, maxItems: MAX_QUESTIONS }),
57
79
  header: Type.Optional(Type.String({ maxLength: LIMITS.header })),
58
80
  displayMode: Type.Optional(StringEnum(["overlay", "inline"] as const)),
59
81
  timeoutPerQuestionMs: Type.Optional(Type.Number({ minimum: 0 })),
60
- });
82
+ }, { additionalProperties: false });
61
83
 
62
84
  export class AskUserValidationError extends Error {
63
85
  readonly code = "invalid_request";
64
-
65
86
  constructor(message: string) {
66
87
  super(message);
67
88
  this.name = "AskUserValidationError";
@@ -72,14 +93,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
72
93
  return typeof value === "object" && value !== null && !Array.isArray(value);
73
94
  }
74
95
 
75
- /** Reject any field that is not part of the declared contract. */
76
96
  function assertKnownKeys(record: Record<string, unknown>, allowed: readonly string[], where: string): void {
77
97
  for (const key of Object.keys(record)) {
78
98
  if (!allowed.includes(key)) throw new AskUserValidationError(`${where} has an unknown field "${key}".`);
79
99
  }
80
100
  }
81
101
 
82
- /** A required, non-empty, length-bounded string; returns the trimmed value. */
83
102
  function requireText(value: unknown, where: string, maxLength: number): string {
84
103
  if (typeof value !== "string") throw new AskUserValidationError(`${where} must be a string.`);
85
104
  const text = value.trim();
@@ -88,7 +107,6 @@ function requireText(value: unknown, where: string, maxLength: number): string {
88
107
  return text;
89
108
  }
90
109
 
91
- /** An optional, length-bounded string; absent stays absent. */
92
110
  function optionalText(value: unknown, where: string, maxLength: number): string | undefined {
93
111
  if (value === undefined) return undefined;
94
112
  if (typeof value !== "string") throw new AskUserValidationError(`${where} must be a string.`);
@@ -105,7 +123,6 @@ function optionalQuestionId(value: unknown, where: string): string | undefined {
105
123
  return id;
106
124
  }
107
125
 
108
- /** A canonical option object: `{ label, description?, preview? }`. */
109
126
  function readOption(value: unknown, where: string, position: number): AskUserOption {
110
127
  const at = `${where} option ${position + 1}`;
111
128
  if (!isRecord(value)) throw new AskUserValidationError(`${at} must be an object with a "label".`);
@@ -119,137 +136,219 @@ function readOption(value: unknown, where: string, position: number): AskUserOpt
119
136
  return option;
120
137
  }
121
138
 
122
- function readQuestion(value: unknown, index: number): { question: AskUserQuestion; warnings: string[] } {
139
+ function readOptions(value: unknown, where: string, warnings: string[]): AskUserOption[] {
140
+ if (value !== undefined && !Array.isArray(value)) throw new AskUserValidationError(`${where} "options" must be an array.`);
141
+ const raw = value ?? [];
142
+ if (raw.length > MAX_OPTIONS) throw new AskUserValidationError(`${where} has more than ${MAX_OPTIONS} options.`);
143
+ const options: AskUserOption[] = [];
144
+ const seen = new Set<string>();
145
+ raw.forEach((entry: unknown, position: number) => {
146
+ const option = readOption(entry, where, position);
147
+ if (seen.has(option.label)) {
148
+ warnings.push(`${where} has a duplicate option "${option.label}"; de-duplicated.`);
149
+ return;
150
+ }
151
+ seen.add(option.label);
152
+ options.push(option);
153
+ });
154
+ return options;
155
+ }
156
+
157
+ function readFieldName(value: unknown, where: string): string {
158
+ if (typeof value !== "string") throw new AskUserValidationError(`${where} name must be a string.`);
159
+ if (value.length === 0) throw new AskUserValidationError(`${where} name must not be empty.`);
160
+ if (value.length > LIMITS.fieldName) throw new AskUserValidationError(`${where} name exceeds the ${LIMITS.fieldName}-character limit.`);
161
+ if (!SAFE_FIELD_NAME.test(value)) {
162
+ throw new AskUserValidationError(`${where} name must be a literal key using letters, digits, "_", or "-", and may not contain a path.`);
163
+ }
164
+ if (UNSAFE_OBJECT_KEYS.has(value)) throw new AskUserValidationError(`${where} name "${value}" is reserved and unsafe.`);
165
+ return value;
166
+ }
167
+
168
+ function readFormField(value: unknown, questionWhere: string, position: number, warnings: string[]): NormalizedFormField {
169
+ const where = `${questionWhere} field ${position + 1}`;
170
+ if (!isRecord(value)) throw new AskUserValidationError(`${where} must be an object.`);
171
+ assertKnownKeys(value, FORM_FIELD_FIELDS, where);
172
+ const name = readFieldName(value.name, where);
173
+ const label = requireText(value.label, `${where} label`, LIMITS.label);
174
+ if (typeof value.kind !== "string" || !FIELD_KINDS.includes(value.kind as FormFieldKind)) {
175
+ throw new AskUserValidationError(`${where} "kind" must be "bool", "single", "multi", or "input"; forms cannot nest.`);
176
+ }
177
+ const kind = value.kind as FormFieldKind;
178
+ if (value.required !== undefined && typeof value.required !== "boolean") {
179
+ throw new AskUserValidationError(`${where} "required" must be a boolean.`);
180
+ }
181
+ const required = value.required === true;
182
+ if ((kind === "bool" || kind === "input") && value.options !== undefined) {
183
+ throw new AskUserValidationError(`${where} is a "${kind}" field and must not have "options".`);
184
+ }
185
+ const options = readOptions(value.options, where, warnings);
186
+ if ((kind === "single" || kind === "multi") && options.length === 0) {
187
+ throw new AskUserValidationError(`${where} is a "${kind}" field but has no options.`);
188
+ }
189
+
190
+ const field: NormalizedFormField = { name, label, kind, required, options, hasDefault: value.default !== undefined };
191
+ if (value.default === undefined) return field;
192
+ if (kind === "bool") {
193
+ if (typeof value.default !== "boolean") throw new AskUserValidationError(`${where} bool default must be a boolean.`);
194
+ field.default = value.default;
195
+ return field;
196
+ }
197
+ if (kind === "input") {
198
+ if (typeof value.default !== "string") throw new AskUserValidationError(`${where} input default must be a string.`);
199
+ if (value.default.length > LIMITS.defaultValue) throw new AskUserValidationError(`${where} default exceeds the ${LIMITS.defaultValue}-character limit.`);
200
+ if (required && value.default.trim() === "") throw new AskUserValidationError(`${where} required input default must not be empty.`);
201
+ field.default = value.default;
202
+ return field;
203
+ }
204
+ if (kind === "single") {
205
+ if (typeof value.default !== "string") throw new AskUserValidationError(`${where} single default must be an option-label string.`);
206
+ if (!options.some((option) => option.label === value.default)) throw new AskUserValidationError(`${where} default must match one of its option labels.`);
207
+ field.default = value.default;
208
+ return field;
209
+ }
210
+ if (!Array.isArray(value.default)) throw new AskUserValidationError(`${where} multi default must be an array of option-label strings.`);
211
+ if (value.default.length > MAX_OPTIONS) throw new AskUserValidationError(`${where} multi default has more than ${MAX_OPTIONS} labels.`);
212
+ if (required && value.default.length === 0) throw new AskUserValidationError(`${where} required multi default must select at least one label.`);
213
+ const selected: string[] = [];
214
+ for (const [defaultIndex, entry] of value.default.entries()) {
215
+ const labelValue = requireText(entry, `${where} default item ${defaultIndex + 1}`, LIMITS.label);
216
+ if (!options.some((option) => option.label === labelValue)) throw new AskUserValidationError(`${where} default item "${labelValue}" is not an option label.`);
217
+ if (selected.includes(labelValue)) throw new AskUserValidationError(`${where} multi default contains duplicate label "${labelValue}".`);
218
+ selected.push(labelValue);
219
+ }
220
+ field.default = selected;
221
+ return field;
222
+ }
223
+
224
+ interface ParsedQuestion {
225
+ id?: string;
226
+ title: string;
227
+ prompt?: string;
228
+ kind: QuestionKind;
229
+ options: AskUserOption[];
230
+ default?: string | boolean;
231
+ hasDefault: boolean;
232
+ fields: NormalizedFormField[];
233
+ }
234
+
235
+ function readQuestion(value: unknown, index: number): { question: ParsedQuestion; warnings: string[] } {
123
236
  const warnings: string[] = [];
124
237
  const where = `Question ${index + 1}`;
125
238
  if (!isRecord(value)) throw new AskUserValidationError(`${where} must be an object.`);
126
239
  assertKnownKeys(value, QUESTION_FIELDS, where);
127
-
128
240
  const title = requireText(value.title, `${where} title`, LIMITS.title);
129
241
 
130
242
  let kind: QuestionKind | undefined;
131
243
  if (value.kind !== undefined) {
132
244
  if (typeof value.kind !== "string" || !QUESTION_KINDS.includes(value.kind as QuestionKind)) {
133
- throw new AskUserValidationError(`${where} "kind" must be "single", "multi", or "input".`);
245
+ throw new AskUserValidationError(`${where} "kind" must be "single", "multi", "input", "bool", or "simple-form".`);
134
246
  }
135
247
  kind = value.kind as QuestionKind;
136
248
  }
137
-
138
- const rawOptions = value.options;
139
- if (rawOptions !== undefined && !Array.isArray(rawOptions)) {
140
- throw new AskUserValidationError(`${where} "options" must be an array.`);
141
- }
142
- const rawOptionList = rawOptions ?? [];
143
- if (rawOptionList.length > MAX_OPTIONS) {
144
- throw new AskUserValidationError(`${where} has more than ${MAX_OPTIONS} options.`);
249
+ if ((kind === "bool" || kind === "simple-form") && value.options !== undefined) {
250
+ throw new AskUserValidationError(`${where} is a "${kind}" question and must not have "options".`);
145
251
  }
146
-
147
- const options: AskUserOption[] = [];
148
- const seen = new Set<string>();
149
- rawOptionList.forEach((rawOption, position) => {
150
- const option = readOption(rawOption, where, position);
151
- if (seen.has(option.label)) {
152
- warnings.push(`${where} has a duplicate option "${option.label}"; de-duplicated.`);
153
- return;
154
- }
155
- seen.add(option.label);
156
- options.push(option);
157
- });
158
-
159
- // When `kind` is omitted it is inferred from `options`: present ⇒ `single`,
160
- // absent ⇒ `input`. `multi` is only ever explicit.
252
+ const options = readOptions(value.options, where, warnings);
161
253
  if (kind === undefined) kind = options.length > 0 ? "single" : "input";
162
- if (kind !== "input" && options.length === 0) {
163
- throw new AskUserValidationError(`${where} is a "${kind}" question but has no options.`);
254
+ if ((kind === "single" || kind === "multi") && options.length === 0) throw new AskUserValidationError(`${where} is a "${kind}" question but has no options.`);
255
+ if (kind !== "simple-form" && value.fields !== undefined) throw new AskUserValidationError(`${where} is a "${kind}" question and must not have "fields".`);
256
+
257
+ const fields: NormalizedFormField[] = [];
258
+ if (kind === "simple-form") {
259
+ if (value.default !== undefined) throw new AskUserValidationError(`${where} simple-form uses field defaults and must not have a top-level "default".`);
260
+ if (!Array.isArray(value.fields)) throw new AskUserValidationError(`${where} simple-form must have a "fields" array.`);
261
+ if (value.fields.length === 0) throw new AskUserValidationError(`${where} simple-form must have at least 1 field.`);
262
+ if (value.fields.length > MAX_FORM_FIELDS) throw new AskUserValidationError(`${where} has more than ${MAX_FORM_FIELDS} fields.`);
263
+ const names = new Set<string>();
264
+ value.fields.forEach((entry, fieldIndex) => {
265
+ const field = readFormField(entry, where, fieldIndex, warnings);
266
+ if (names.has(field.name)) throw new AskUserValidationError(`${where} has duplicate field name "${field.name}".`);
267
+ names.add(field.name);
268
+ fields.push(field);
269
+ });
270
+ } else if (kind === "bool") {
271
+ if (value.default !== undefined && typeof value.default !== "boolean") throw new AskUserValidationError(`${where} bool default must be a boolean.`);
272
+ } else if (value.default !== undefined && typeof value.default !== "string") {
273
+ throw new AskUserValidationError(`${where} default must be a string.`);
164
274
  }
165
275
 
166
- const question: AskUserQuestion = { title, kind };
276
+ const question: ParsedQuestion = {
277
+ title,
278
+ kind,
279
+ options: kind === "input" ? [] : options,
280
+ hasDefault: value.default !== undefined,
281
+ fields,
282
+ };
167
283
  const prompt = optionalText(value.prompt, `${where} prompt`, LIMITS.prompt);
168
284
  if (prompt !== undefined) question.prompt = prompt;
169
- if (kind === "input") {
170
- delete question.options;
171
- } else {
172
- question.options = options;
173
- }
174
285
  const id = optionalQuestionId(value.id, where);
175
286
  if (id) question.id = id;
176
- const fallback = optionalText(value.default, `${where} default`, LIMITS.defaultValue);
177
- if (fallback !== undefined) question.default = fallback;
287
+ if (value.default !== undefined) {
288
+ if (typeof value.default === "string" && value.default.length > LIMITS.defaultValue) throw new AskUserValidationError(`${where} default exceeds the ${LIMITS.defaultValue}-character limit.`);
289
+ question.default = value.default as string | boolean;
290
+ }
178
291
  return { question, warnings };
179
292
  }
180
293
 
181
294
  function readDisplayMode(value: unknown): "overlay" | "inline" {
182
295
  if (value === undefined) return "overlay";
183
- if (value !== "overlay" && value !== "inline") {
184
- throw new AskUserValidationError('displayMode must be "overlay" or "inline".');
185
- }
296
+ if (value !== "overlay" && value !== "inline") throw new AskUserValidationError('displayMode must be "overlay" or "inline".');
186
297
  return value;
187
298
  }
188
299
 
189
300
  function readTimeoutPerQuestionMs(value: unknown): number {
190
301
  if (value === undefined) return DEFAULT_TIMEOUT_PER_QUESTION_MS;
191
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
192
- throw new AskUserValidationError("timeoutPerQuestionMs must be a non-negative finite number.");
193
- }
302
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new AskUserValidationError("timeoutPerQuestionMs must be a non-negative finite number.");
194
303
  return Math.floor(value);
195
304
  }
196
305
 
197
- /** Normalize and validate a request against the {@link AskUserParams} contract. */
306
+ /** Normalize and validate a request against the exact tool/direct-call contract. */
198
307
  export function normalizeAskUserRequest(raw: unknown): { request: NormalizedRequest; warnings: string[] } {
199
308
  if (!isRecord(raw)) throw new AskUserValidationError("Missing the questions array.");
200
- for (const key of Object.keys(raw)) {
201
- if (!REQUEST_FIELDS.includes(key as (typeof REQUEST_FIELDS)[number])) {
202
- throw new AskUserValidationError(`Unknown field "${key}".`);
203
- }
204
- }
309
+ assertKnownKeys(raw, REQUEST_FIELDS, "Request");
205
310
  const rawQuestions = raw.questions;
206
311
  if (!Array.isArray(rawQuestions)) throw new AskUserValidationError("Missing the questions array.");
207
312
  if (rawQuestions.length === 0) throw new AskUserValidationError("At least 1 question is required.");
208
- if (rawQuestions.length > MAX_QUESTIONS) {
209
- throw new AskUserValidationError(`At most ${MAX_QUESTIONS} questions; received ${rawQuestions.length}.`);
210
- }
313
+ if (rawQuestions.length > MAX_QUESTIONS) throw new AskUserValidationError(`At most ${MAX_QUESTIONS} questions; received ${rawQuestions.length}.`);
211
314
 
212
315
  const warnings: string[] = [];
213
316
  const usedIds = new Set<string>();
214
317
  const questions: NormalizedQuestion[] = [];
215
318
  rawQuestions.forEach((rawQuestion, index) => {
216
- const { question, warnings: questionWarnings } = readQuestion(rawQuestion, index);
217
- warnings.push(...questionWarnings);
218
-
319
+ const parsed = readQuestion(rawQuestion, index);
320
+ warnings.push(...parsed.warnings);
321
+ const question = parsed.question;
219
322
  let id = question.id ?? "";
220
323
  if (id === "" || usedIds.has(id)) {
221
324
  let candidate = `q${index + 1}`;
222
325
  let suffix = 1;
223
- while (usedIds.has(candidate)) {
224
- candidate = `q${index + 1}_${suffix++}`;
225
- }
326
+ while (usedIds.has(candidate)) candidate = `q${index + 1}_${suffix++}`;
226
327
  id = candidate;
227
328
  }
228
329
  usedIds.add(id);
229
-
230
330
  const normalized: NormalizedQuestion = {
231
331
  id,
232
332
  index,
233
333
  title: question.title,
234
334
  kind: question.kind,
235
- options: question.options ?? [],
236
- hasDefault: question.default !== undefined,
335
+ options: question.options,
336
+ hasDefault: question.hasDefault,
337
+ fields: question.fields,
237
338
  };
238
339
  if (question.prompt !== undefined) normalized.prompt = question.prompt;
239
340
  if (question.default !== undefined) normalized.default = question.default;
240
341
  questions.push(normalized);
241
342
  });
242
343
 
243
- const header = optionalText(raw.header, "header", LIMITS.header);
244
- const displayMode = readDisplayMode(raw.displayMode);
245
344
  const timeoutPerQuestionMs = readTimeoutPerQuestionMs(raw.timeoutPerQuestionMs);
246
-
247
345
  const request: NormalizedRequest = {
248
346
  questions,
249
- displayMode,
347
+ displayMode: readDisplayMode(raw.displayMode),
250
348
  timeoutPerQuestionMs,
251
349
  totalTimeoutMs: timeoutPerQuestionMs * questions.length,
252
350
  };
351
+ const header = optionalText(raw.header, "header", LIMITS.header);
253
352
  if (header !== undefined) request.header = header;
254
353
  return { request, warnings };
255
354
  }
package/src/types.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * answered through one of two routes:
6
6
  *
7
7
  * - `custom` — a custom terminal UI (only a real Pi TUI)
8
- * - `native` — native input dialogs (`ctx.hasUI` + callable input), one question
9
- * at a time
8
+ * - `native` — native input dialogs (`ctx.hasUI` + callable input), one logical
9
+ * question at a time (a form uses sequential per-field dialogs)
10
10
  *
11
11
  * The route is resolved **once, when the host adapter is created**, from
12
12
  * trustworthy inputs only:
@@ -25,7 +25,10 @@
25
25
  */
26
26
 
27
27
  /** The kind of question. */
28
- export type QuestionKind = "single" | "multi" | "input";
28
+ export type QuestionKind = "single" | "multi" | "input" | "bool" | "simple-form";
29
+
30
+ /** The non-nesting field kinds accepted by a `simple-form` question. */
31
+ export type FormFieldKind = Exclude<QuestionKind, "simple-form">;
29
32
 
30
33
  /** Custom-UI presentation: floating overlay or inline prompt area. */
31
34
  export type AskUserDisplayMode = "overlay" | "inline";
@@ -40,25 +43,98 @@ export interface AskUserOption {
40
43
  preview?: string;
41
44
  }
42
45
 
43
- /** One question in a questionnaire. */
44
- export interface AskUserQuestion {
46
+ interface AskUserFormFieldBase {
47
+ /** Literal output key: 1..64 ASCII letters/digits/`_`/`-`, starting with a letter or `_`. */
48
+ name: string;
49
+ /** Label shown beside the field. */
50
+ label: string;
51
+ /** When true, an explicit non-empty value (or a valid default) is required. */
52
+ required?: boolean;
53
+ }
54
+
55
+ export interface AskUserBoolField extends AskUserFormFieldBase {
56
+ kind: "bool";
57
+ options?: never;
58
+ default?: boolean;
59
+ }
60
+
61
+ export interface AskUserSingleField extends AskUserFormFieldBase {
62
+ kind: "single";
63
+ options: AskUserOption[];
64
+ default?: string;
65
+ }
66
+
67
+ export interface AskUserMultiField extends AskUserFormFieldBase {
68
+ kind: "multi";
69
+ options: AskUserOption[];
70
+ default?: string[];
71
+ }
72
+
73
+ export interface AskUserInputField extends AskUserFormFieldBase {
74
+ kind: "input";
75
+ options?: never;
76
+ default?: string;
77
+ }
78
+
79
+ /** One non-nesting, kind-typed field in a flat `simple-form` question. */
80
+ export type AskUserFormField = AskUserBoolField | AskUserSingleField | AskUserMultiField | AskUserInputField;
81
+
82
+ /** Flat typed value returned by a `simple-form`. */
83
+ export type AskUserFormValue = Record<string, boolean | string | string[]>;
84
+
85
+ interface AskUserQuestionBase {
45
86
  /** Stable id. Generated (`q1`, `q2`, ...) when omitted. */
46
87
  id?: string;
47
88
  /** Question heading (required). */
48
89
  title: string;
49
90
  /** Optional extra hint or context rendered under the heading. */
50
91
  prompt?: string;
51
- /** `single` = choose one, `multi` = choose several, `input` = free text only. */
52
- kind: QuestionKind;
53
- /** 1..5 options. Required for `single`/`multi`; ignored for `input`. */
92
+ }
93
+
94
+ export interface AskUserSingleQuestion extends AskUserQuestionBase {
95
+ kind: "single";
96
+ options: AskUserOption[];
97
+ default?: string;
98
+ fields?: never;
99
+ }
100
+
101
+ export interface AskUserMultiQuestion extends AskUserQuestionBase {
102
+ kind: "multi";
103
+ options: AskUserOption[];
104
+ default?: string;
105
+ fields?: never;
106
+ }
107
+
108
+ export interface AskUserInputQuestion extends AskUserQuestionBase {
109
+ kind: "input";
110
+ /** Kept for legacy callers: validated by normalization, then dropped. */
54
111
  options?: AskUserOption[];
55
- /**
56
- * Default answer. When present the question may be skipped and the default
57
- * is used; when absent the question must be answered.
58
- */
59
112
  default?: string;
113
+ fields?: never;
114
+ }
115
+
116
+ export interface AskUserBoolQuestion extends AskUserQuestionBase {
117
+ kind: "bool";
118
+ options?: never;
119
+ default?: boolean;
120
+ fields?: never;
121
+ }
122
+
123
+ export interface AskUserSimpleFormQuestion extends AskUserQuestionBase {
124
+ kind: "simple-form";
125
+ options?: never;
126
+ default?: never;
127
+ fields: AskUserFormField[];
60
128
  }
61
129
 
130
+ /** One kind-typed question in a questionnaire. */
131
+ export type AskUserQuestion =
132
+ | AskUserSingleQuestion
133
+ | AskUserMultiQuestion
134
+ | AskUserInputQuestion
135
+ | AskUserBoolQuestion
136
+ | AskUserSimpleFormQuestion;
137
+
62
138
  /** A model-facing (or caller-facing) request. */
63
139
  export interface AskUserRequest {
64
140
  /** 1..5 questions. */
@@ -88,6 +164,8 @@ export interface AskUserAnswer {
88
164
  freeText?: string;
89
165
  /** True when the answer came from the question default rather than user input. */
90
166
  usedDefault: boolean;
167
+ /** Typed value for `bool` and `simple-form`. `false` is a complete answer. */
168
+ value?: boolean | AskUserFormValue;
91
169
  }
92
170
 
93
171
  /**
@@ -325,7 +403,19 @@ export interface NormalizedQuestion {
325
403
  prompt?: string;
326
404
  kind: QuestionKind;
327
405
  options: AskUserOption[];
328
- default?: string;
406
+ default?: string | boolean;
407
+ hasDefault: boolean;
408
+ /** Present for `simple-form`; omitted by older already-normalized callers. */
409
+ fields?: NormalizedFormField[];
410
+ }
411
+
412
+ export interface NormalizedFormField {
413
+ name: string;
414
+ label: string;
415
+ kind: FormFieldKind;
416
+ required: boolean;
417
+ options: AskUserOption[];
418
+ default?: boolean | string | string[];
329
419
  hasDefault: boolean;
330
420
  }
331
421