@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/README.md +121 -25
- package/package.json +1 -1
- package/src/adapters/pi.ts +3 -1
- package/src/answers.ts +118 -33
- package/src/config.ts +11 -9
- package/src/core.ts +23 -6
- package/src/index.ts +17 -2
- package/src/parse.ts +7 -0
- package/src/schema.ts +188 -89
- package/src/types.ts +103 -13
- package/src/ui/custom.ts +393 -9
- package/src/ui/native.ts +162 -59
package/src/schema.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
-
import type {
|
|
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
|
|
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
|
-
|
|
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
|
|
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 "
|
|
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
|
-
|
|
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
|
|
163
|
-
|
|
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:
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
217
|
-
warnings.push(...
|
|
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.
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|