@black942026/pi-ask-user 0.1.1 → 0.1.3
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 +160 -25
- package/package.json +1 -1
- package/src/adapters/pi.ts +5 -2
- package/src/answers.ts +119 -33
- package/src/config.ts +11 -9
- package/src/core.ts +23 -6
- package/src/index.ts +19 -3
- package/src/parse.ts +17 -0
- package/src/schema.ts +201 -90
- package/src/types.ts +113 -13
- package/src/ui/custom.ts +395 -16
- package/src/ui/native.ts +232 -60
package/src/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ export * from "./types.ts";
|
|
|
23
23
|
export { askUser, askUserNormalized } from "./core.ts";
|
|
24
24
|
export { createPiHost, createPiCustomRenderer, type PiHostOptions } from "./adapters/pi.ts";
|
|
25
25
|
export { createAskUser, type AskUser, type CreateAskUserOptions } from "./ask.ts";
|
|
26
|
-
export { createNativeRunner, type NativeDialogUI } from "./ui/native.ts";
|
|
26
|
+
export { createNativeRunner, type NativeDialogUI, type NativeDialogOptions } from "./ui/native.ts";
|
|
27
27
|
export {
|
|
28
28
|
AskUserComponent,
|
|
29
29
|
MIN_USABLE_ROWS,
|
|
@@ -38,6 +38,7 @@ export {
|
|
|
38
38
|
DEFAULT_TIMEOUT_PER_QUESTION_MS,
|
|
39
39
|
LIMITS,
|
|
40
40
|
MAX_OPTIONS,
|
|
41
|
+
MAX_FORM_FIELDS,
|
|
41
42
|
MAX_QUESTIONS,
|
|
42
43
|
normalizeAskUserRequest,
|
|
43
44
|
} from "./schema.ts";
|
|
@@ -77,6 +78,7 @@ export {
|
|
|
77
78
|
emptyDraft,
|
|
78
79
|
finalizeAnswers,
|
|
79
80
|
toAnswer,
|
|
81
|
+
validateAnswer,
|
|
80
82
|
type DraftAnswer,
|
|
81
83
|
type FinalizeResult,
|
|
82
84
|
} from "./answers.ts";
|
|
@@ -98,7 +100,7 @@ export interface AskUserToolDetails {
|
|
|
98
100
|
progress?: string;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
const DESCRIPTION = "Ask the user
|
|
103
|
+
const DESCRIPTION = "Ask the user structured single-choice, multi-choice, text, boolean, or flat form questions through the host's interactive UI.";
|
|
102
104
|
|
|
103
105
|
const PROMPT_SNIPPET = "Ask the user structured questions when you would otherwise guess.";
|
|
104
106
|
|
|
@@ -106,6 +108,8 @@ const PROMPT_GUIDELINES = [
|
|
|
106
108
|
"Use ask_user when a choice is high-impact or ambiguous and you cannot infer the answer.",
|
|
107
109
|
"If the tool reports the UI could not run, ask the user the question yourself in ordinary text instead.",
|
|
108
110
|
"Never invent an answer; on aborted or timeout, report it plainly and do not re-ask.",
|
|
111
|
+
"Use kind=bool for a real boolean. Use kind=simple-form with flat uniquely named fields when several typed values belong in one logical question; forms cannot nest.",
|
|
112
|
+
"Set allowFreeText=false to require option selections without supplemental text. Native single/bool then use selectors; native multi still uses a numbers-only text box.",
|
|
109
113
|
];
|
|
110
114
|
|
|
111
115
|
/**
|
|
@@ -136,6 +140,12 @@ function describeRequestAsText(request: NormalizedRequest): string {
|
|
|
136
140
|
const description = option.description?.trim();
|
|
137
141
|
lines.push(` ${optionIndex + 1}) ${option.label}${description ? ` — ${description}` : ""}`);
|
|
138
142
|
});
|
|
143
|
+
if (question.kind === "bool") lines.push(" answer: true or false");
|
|
144
|
+
for (const field of question.fields ?? []) {
|
|
145
|
+
lines.push(` - ${field.label} [${field.name}: ${field.kind}]${field.required ? " required" : " optional"}`);
|
|
146
|
+
field.options.forEach((option, optionIndex) => lines.push(` ${optionIndex + 1}) ${option.label}`));
|
|
147
|
+
if (field.hasDefault) lines.push(` default: ${JSON.stringify(field.default)}`);
|
|
148
|
+
}
|
|
139
149
|
if (question.hasDefault && question.default !== undefined) lines.push(` default: ${question.default}`);
|
|
140
150
|
});
|
|
141
151
|
return lines.join("\n");
|
|
@@ -151,6 +161,10 @@ function buildModelContent(result: AskUserResult, request: NormalizedRequest): s
|
|
|
151
161
|
switch (result.status) {
|
|
152
162
|
case "answered": {
|
|
153
163
|
const lines = result.answers.map((answer) => {
|
|
164
|
+
if (answer.value !== undefined) {
|
|
165
|
+
const suffix = answer.usedDefault ? " (default used)" : "";
|
|
166
|
+
return `- ${answer.title}: ${JSON.stringify(answer.value)}${suffix}`;
|
|
167
|
+
}
|
|
154
168
|
const parts: string[] = [];
|
|
155
169
|
if (answer.selections.length > 0) parts.push(answer.selections.join(", "));
|
|
156
170
|
if (answer.freeText) parts.push(answer.freeText);
|
|
@@ -211,7 +225,9 @@ export interface RegisterAskUserOptions {
|
|
|
211
225
|
timeoutPerQuestionMs?: number;
|
|
212
226
|
/**
|
|
213
227
|
* Path to the user config file, or `false` to skip reading it. Defaults to
|
|
214
|
-
* `~/.pi/ask-user
|
|
228
|
+
* `<Pi agent directory>/ask-user.json` (normally `~/.pi/agent/ask-user.json`).
|
|
229
|
+
* The directory follows Pi's `PI_CODING_AGENT_DIR`; there are no AskUser-specific
|
|
230
|
+
* environment-variable preferences.
|
|
215
231
|
*/
|
|
216
232
|
configFile?: string | false;
|
|
217
233
|
}
|
package/src/parse.ts
CHANGED
|
@@ -54,6 +54,9 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
|
|
|
54
54
|
if (question.kind === "input") {
|
|
55
55
|
return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
|
|
56
56
|
}
|
|
57
|
+
if (question.kind === "bool" || question.kind === "simple-form") {
|
|
58
|
+
return { ok: false, error: `${question.kind} uses typed native input and cannot be parsed as a legacy answer.` };
|
|
59
|
+
}
|
|
57
60
|
|
|
58
61
|
const pipeIndex = raw.indexOf("|");
|
|
59
62
|
const left = pipeIndex >= 0 ? raw.slice(0, pipeIndex) : raw;
|
|
@@ -63,12 +66,14 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
|
|
|
63
66
|
|
|
64
67
|
// No number in the box: the (optional) right side is the free input.
|
|
65
68
|
if (leftTrim === "") {
|
|
69
|
+
if (question.allowFreeText === false) return { ok: false, error: "Free text is disabled; enter option numbers only." };
|
|
66
70
|
return { ok: true, value: { selections: [], freeText: comment, empty: comment === "" } };
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
const isStrictList = DIGIT_SEPARATOR_ONLY.test(leftTrim);
|
|
70
74
|
const isListAttempt = isStrictList || NUMBER_LIST_ATTEMPT.test(leftTrim);
|
|
71
75
|
if (!isListAttempt) {
|
|
76
|
+
if (question.allowFreeText === false) return { ok: false, error: "Free text is disabled; enter option numbers only." };
|
|
72
77
|
// Free text, taken verbatim including any `|`.
|
|
73
78
|
return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
|
|
74
79
|
}
|
|
@@ -93,6 +98,9 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
|
|
|
93
98
|
return { ok: false, error: "This is a single-select question; enter only one number." };
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
if (question.allowFreeText === false && pipeIndex >= 0) {
|
|
102
|
+
return { ok: false, error: "Notes are disabled; enter option numbers only." };
|
|
103
|
+
}
|
|
96
104
|
const ordered = question.kind === "single" ? unique : [...unique].sort((a, b) => a - b);
|
|
97
105
|
const value: ParsedAnswer = {
|
|
98
106
|
selections: question.kind === "single" ? [ordered[0]!] : ordered,
|
|
@@ -104,6 +112,11 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
|
|
|
104
112
|
|
|
105
113
|
/** Render the per-question input instruction used by the native route. */
|
|
106
114
|
export function nativeInputHint(question: NormalizedQuestion): string {
|
|
115
|
+
if (question.allowFreeText === false && (question.kind === "single" || question.kind === "multi")) {
|
|
116
|
+
return question.kind === "single"
|
|
117
|
+
? "Single select. Reply with one option number only (e.g. 2)."
|
|
118
|
+
: "Multi select. Reply with option numbers only, separated by commas (e.g. 1,3).";
|
|
119
|
+
}
|
|
107
120
|
switch (question.kind) {
|
|
108
121
|
case "single":
|
|
109
122
|
return question.options.length > 0
|
|
@@ -115,5 +128,9 @@ export function nativeInputHint(question: NormalizedQuestion): string {
|
|
|
115
128
|
: "Free input. Reply with text to answer.";
|
|
116
129
|
case "input":
|
|
117
130
|
return "Free input. Reply with text to answer.";
|
|
131
|
+
case "bool":
|
|
132
|
+
return "Boolean. Reply with true or false.";
|
|
133
|
+
case "simple-form":
|
|
134
|
+
return "Form fields are asked one at a time.";
|
|
118
135
|
}
|
|
119
136
|
}
|
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
|
-
const REQUEST_FIELDS = ["questions", "header", "displayMode", "timeoutPerQuestionMs"] as const;
|
|
33
|
+
const REQUEST_FIELDS = ["questions", "header", "displayMode", "timeoutPerQuestionMs", "allowFreeText"] 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,21 @@ 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 })),
|
|
80
|
+
allowFreeText: Type.Optional(Type.Boolean({ description: "Allow supplemental/free-text answers on single/multi questions (default true). False hides the extra text input and requires option selections; native single/bool use selectors (including form fields), while native multi/text keep input boxes." })),
|
|
58
81
|
displayMode: Type.Optional(StringEnum(["overlay", "inline"] as const)),
|
|
59
82
|
timeoutPerQuestionMs: Type.Optional(Type.Number({ minimum: 0 })),
|
|
60
|
-
});
|
|
83
|
+
}, { additionalProperties: false });
|
|
61
84
|
|
|
62
85
|
export class AskUserValidationError extends Error {
|
|
63
86
|
readonly code = "invalid_request";
|
|
64
|
-
|
|
65
87
|
constructor(message: string) {
|
|
66
88
|
super(message);
|
|
67
89
|
this.name = "AskUserValidationError";
|
|
@@ -72,14 +94,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
72
94
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
73
95
|
}
|
|
74
96
|
|
|
75
|
-
/** Reject any field that is not part of the declared contract. */
|
|
76
97
|
function assertKnownKeys(record: Record<string, unknown>, allowed: readonly string[], where: string): void {
|
|
77
98
|
for (const key of Object.keys(record)) {
|
|
78
99
|
if (!allowed.includes(key)) throw new AskUserValidationError(`${where} has an unknown field "${key}".`);
|
|
79
100
|
}
|
|
80
101
|
}
|
|
81
102
|
|
|
82
|
-
/** A required, non-empty, length-bounded string; returns the trimmed value. */
|
|
83
103
|
function requireText(value: unknown, where: string, maxLength: number): string {
|
|
84
104
|
if (typeof value !== "string") throw new AskUserValidationError(`${where} must be a string.`);
|
|
85
105
|
const text = value.trim();
|
|
@@ -88,7 +108,6 @@ function requireText(value: unknown, where: string, maxLength: number): string {
|
|
|
88
108
|
return text;
|
|
89
109
|
}
|
|
90
110
|
|
|
91
|
-
/** An optional, length-bounded string; absent stays absent. */
|
|
92
111
|
function optionalText(value: unknown, where: string, maxLength: number): string | undefined {
|
|
93
112
|
if (value === undefined) return undefined;
|
|
94
113
|
if (typeof value !== "string") throw new AskUserValidationError(`${where} must be a string.`);
|
|
@@ -105,7 +124,6 @@ function optionalQuestionId(value: unknown, where: string): string | undefined {
|
|
|
105
124
|
return id;
|
|
106
125
|
}
|
|
107
126
|
|
|
108
|
-
/** A canonical option object: `{ label, description?, preview? }`. */
|
|
109
127
|
function readOption(value: unknown, where: string, position: number): AskUserOption {
|
|
110
128
|
const at = `${where} option ${position + 1}`;
|
|
111
129
|
if (!isRecord(value)) throw new AskUserValidationError(`${at} must be an object with a "label".`);
|
|
@@ -119,137 +137,230 @@ function readOption(value: unknown, where: string, position: number): AskUserOpt
|
|
|
119
137
|
return option;
|
|
120
138
|
}
|
|
121
139
|
|
|
122
|
-
function
|
|
140
|
+
function readOptions(value: unknown, where: string, warnings: string[]): AskUserOption[] {
|
|
141
|
+
if (value !== undefined && !Array.isArray(value)) throw new AskUserValidationError(`${where} "options" must be an array.`);
|
|
142
|
+
const raw = value ?? [];
|
|
143
|
+
if (raw.length > MAX_OPTIONS) throw new AskUserValidationError(`${where} has more than ${MAX_OPTIONS} options.`);
|
|
144
|
+
const options: AskUserOption[] = [];
|
|
145
|
+
const seen = new Set<string>();
|
|
146
|
+
raw.forEach((entry: unknown, position: number) => {
|
|
147
|
+
const option = readOption(entry, where, position);
|
|
148
|
+
if (seen.has(option.label)) {
|
|
149
|
+
warnings.push(`${where} has a duplicate option "${option.label}"; de-duplicated.`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
seen.add(option.label);
|
|
153
|
+
options.push(option);
|
|
154
|
+
});
|
|
155
|
+
return options;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readFieldName(value: unknown, where: string): string {
|
|
159
|
+
if (typeof value !== "string") throw new AskUserValidationError(`${where} name must be a string.`);
|
|
160
|
+
if (value.length === 0) throw new AskUserValidationError(`${where} name must not be empty.`);
|
|
161
|
+
if (value.length > LIMITS.fieldName) throw new AskUserValidationError(`${where} name exceeds the ${LIMITS.fieldName}-character limit.`);
|
|
162
|
+
if (!SAFE_FIELD_NAME.test(value)) {
|
|
163
|
+
throw new AskUserValidationError(`${where} name must be a literal key using letters, digits, "_", or "-", and may not contain a path.`);
|
|
164
|
+
}
|
|
165
|
+
if (UNSAFE_OBJECT_KEYS.has(value)) throw new AskUserValidationError(`${where} name "${value}" is reserved and unsafe.`);
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readFormField(value: unknown, questionWhere: string, position: number, warnings: string[]): NormalizedFormField {
|
|
170
|
+
const where = `${questionWhere} field ${position + 1}`;
|
|
171
|
+
if (!isRecord(value)) throw new AskUserValidationError(`${where} must be an object.`);
|
|
172
|
+
assertKnownKeys(value, FORM_FIELD_FIELDS, where);
|
|
173
|
+
const name = readFieldName(value.name, where);
|
|
174
|
+
const label = requireText(value.label, `${where} label`, LIMITS.label);
|
|
175
|
+
if (typeof value.kind !== "string" || !FIELD_KINDS.includes(value.kind as FormFieldKind)) {
|
|
176
|
+
throw new AskUserValidationError(`${where} "kind" must be "bool", "single", "multi", or "input"; forms cannot nest.`);
|
|
177
|
+
}
|
|
178
|
+
const kind = value.kind as FormFieldKind;
|
|
179
|
+
if (value.required !== undefined && typeof value.required !== "boolean") {
|
|
180
|
+
throw new AskUserValidationError(`${where} "required" must be a boolean.`);
|
|
181
|
+
}
|
|
182
|
+
const required = value.required === true;
|
|
183
|
+
if ((kind === "bool" || kind === "input") && value.options !== undefined) {
|
|
184
|
+
throw new AskUserValidationError(`${where} is a "${kind}" field and must not have "options".`);
|
|
185
|
+
}
|
|
186
|
+
const options = readOptions(value.options, where, warnings);
|
|
187
|
+
if ((kind === "single" || kind === "multi") && options.length === 0) {
|
|
188
|
+
throw new AskUserValidationError(`${where} is a "${kind}" field but has no options.`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const field: NormalizedFormField = { name, label, kind, required, options, hasDefault: value.default !== undefined };
|
|
192
|
+
if (value.default === undefined) return field;
|
|
193
|
+
if (kind === "bool") {
|
|
194
|
+
if (typeof value.default !== "boolean") throw new AskUserValidationError(`${where} bool default must be a boolean.`);
|
|
195
|
+
field.default = value.default;
|
|
196
|
+
return field;
|
|
197
|
+
}
|
|
198
|
+
if (kind === "input") {
|
|
199
|
+
if (typeof value.default !== "string") throw new AskUserValidationError(`${where} input default must be a string.`);
|
|
200
|
+
if (value.default.length > LIMITS.defaultValue) throw new AskUserValidationError(`${where} default exceeds the ${LIMITS.defaultValue}-character limit.`);
|
|
201
|
+
if (required && value.default.trim() === "") throw new AskUserValidationError(`${where} required input default must not be empty.`);
|
|
202
|
+
field.default = value.default;
|
|
203
|
+
return field;
|
|
204
|
+
}
|
|
205
|
+
if (kind === "single") {
|
|
206
|
+
if (typeof value.default !== "string") throw new AskUserValidationError(`${where} single default must be an option-label string.`);
|
|
207
|
+
if (!options.some((option) => option.label === value.default)) throw new AskUserValidationError(`${where} default must match one of its option labels.`);
|
|
208
|
+
field.default = value.default;
|
|
209
|
+
return field;
|
|
210
|
+
}
|
|
211
|
+
if (!Array.isArray(value.default)) throw new AskUserValidationError(`${where} multi default must be an array of option-label strings.`);
|
|
212
|
+
if (value.default.length > MAX_OPTIONS) throw new AskUserValidationError(`${where} multi default has more than ${MAX_OPTIONS} labels.`);
|
|
213
|
+
if (required && value.default.length === 0) throw new AskUserValidationError(`${where} required multi default must select at least one label.`);
|
|
214
|
+
const selected: string[] = [];
|
|
215
|
+
for (const [defaultIndex, entry] of value.default.entries()) {
|
|
216
|
+
const labelValue = requireText(entry, `${where} default item ${defaultIndex + 1}`, LIMITS.label);
|
|
217
|
+
if (!options.some((option) => option.label === labelValue)) throw new AskUserValidationError(`${where} default item "${labelValue}" is not an option label.`);
|
|
218
|
+
if (selected.includes(labelValue)) throw new AskUserValidationError(`${where} multi default contains duplicate label "${labelValue}".`);
|
|
219
|
+
selected.push(labelValue);
|
|
220
|
+
}
|
|
221
|
+
field.default = selected;
|
|
222
|
+
return field;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface ParsedQuestion {
|
|
226
|
+
id?: string;
|
|
227
|
+
title: string;
|
|
228
|
+
prompt?: string;
|
|
229
|
+
kind: QuestionKind;
|
|
230
|
+
options: AskUserOption[];
|
|
231
|
+
default?: string | boolean;
|
|
232
|
+
hasDefault: boolean;
|
|
233
|
+
fields: NormalizedFormField[];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function readQuestion(value: unknown, index: number): { question: ParsedQuestion; warnings: string[] } {
|
|
123
237
|
const warnings: string[] = [];
|
|
124
238
|
const where = `Question ${index + 1}`;
|
|
125
239
|
if (!isRecord(value)) throw new AskUserValidationError(`${where} must be an object.`);
|
|
126
240
|
assertKnownKeys(value, QUESTION_FIELDS, where);
|
|
127
|
-
|
|
128
241
|
const title = requireText(value.title, `${where} title`, LIMITS.title);
|
|
129
242
|
|
|
130
243
|
let kind: QuestionKind | undefined;
|
|
131
244
|
if (value.kind !== undefined) {
|
|
132
245
|
if (typeof value.kind !== "string" || !QUESTION_KINDS.includes(value.kind as QuestionKind)) {
|
|
133
|
-
throw new AskUserValidationError(`${where} "kind" must be "single", "multi", or "
|
|
246
|
+
throw new AskUserValidationError(`${where} "kind" must be "single", "multi", "input", "bool", or "simple-form".`);
|
|
134
247
|
}
|
|
135
248
|
kind = value.kind as QuestionKind;
|
|
136
249
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (rawOptions !== undefined && !Array.isArray(rawOptions)) {
|
|
140
|
-
throw new AskUserValidationError(`${where} "options" must be an array.`);
|
|
250
|
+
if ((kind === "bool" || kind === "simple-form") && value.options !== undefined) {
|
|
251
|
+
throw new AskUserValidationError(`${where} is a "${kind}" question and must not have "options".`);
|
|
141
252
|
}
|
|
142
|
-
const
|
|
143
|
-
if (rawOptionList.length > MAX_OPTIONS) {
|
|
144
|
-
throw new AskUserValidationError(`${where} has more than ${MAX_OPTIONS} options.`);
|
|
145
|
-
}
|
|
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.
|
|
253
|
+
const options = readOptions(value.options, where, warnings);
|
|
161
254
|
if (kind === undefined) kind = options.length > 0 ? "single" : "input";
|
|
162
|
-
if (kind
|
|
163
|
-
|
|
255
|
+
if ((kind === "single" || kind === "multi") && options.length === 0) throw new AskUserValidationError(`${where} is a "${kind}" question but has no options.`);
|
|
256
|
+
if (kind !== "simple-form" && value.fields !== undefined) throw new AskUserValidationError(`${where} is a "${kind}" question and must not have "fields".`);
|
|
257
|
+
|
|
258
|
+
const fields: NormalizedFormField[] = [];
|
|
259
|
+
if (kind === "simple-form") {
|
|
260
|
+
if (value.default !== undefined) throw new AskUserValidationError(`${where} simple-form uses field defaults and must not have a top-level "default".`);
|
|
261
|
+
if (!Array.isArray(value.fields)) throw new AskUserValidationError(`${where} simple-form must have a "fields" array.`);
|
|
262
|
+
if (value.fields.length === 0) throw new AskUserValidationError(`${where} simple-form must have at least 1 field.`);
|
|
263
|
+
if (value.fields.length > MAX_FORM_FIELDS) throw new AskUserValidationError(`${where} has more than ${MAX_FORM_FIELDS} fields.`);
|
|
264
|
+
const names = new Set<string>();
|
|
265
|
+
value.fields.forEach((entry, fieldIndex) => {
|
|
266
|
+
const field = readFormField(entry, where, fieldIndex, warnings);
|
|
267
|
+
if (names.has(field.name)) throw new AskUserValidationError(`${where} has duplicate field name "${field.name}".`);
|
|
268
|
+
names.add(field.name);
|
|
269
|
+
fields.push(field);
|
|
270
|
+
});
|
|
271
|
+
} else if (kind === "bool") {
|
|
272
|
+
if (value.default !== undefined && typeof value.default !== "boolean") throw new AskUserValidationError(`${where} bool default must be a boolean.`);
|
|
273
|
+
} else if (value.default !== undefined && typeof value.default !== "string") {
|
|
274
|
+
throw new AskUserValidationError(`${where} default must be a string.`);
|
|
164
275
|
}
|
|
165
276
|
|
|
166
|
-
const question:
|
|
277
|
+
const question: ParsedQuestion = {
|
|
278
|
+
title,
|
|
279
|
+
kind,
|
|
280
|
+
options: kind === "input" ? [] : options,
|
|
281
|
+
hasDefault: value.default !== undefined,
|
|
282
|
+
fields,
|
|
283
|
+
};
|
|
167
284
|
const prompt = optionalText(value.prompt, `${where} prompt`, LIMITS.prompt);
|
|
168
285
|
if (prompt !== undefined) question.prompt = prompt;
|
|
169
|
-
if (kind === "input") {
|
|
170
|
-
delete question.options;
|
|
171
|
-
} else {
|
|
172
|
-
question.options = options;
|
|
173
|
-
}
|
|
174
286
|
const id = optionalQuestionId(value.id, where);
|
|
175
287
|
if (id) question.id = id;
|
|
176
|
-
|
|
177
|
-
|
|
288
|
+
if (value.default !== undefined) {
|
|
289
|
+
if (typeof value.default === "string" && value.default.length > LIMITS.defaultValue) throw new AskUserValidationError(`${where} default exceeds the ${LIMITS.defaultValue}-character limit.`);
|
|
290
|
+
question.default = value.default as string | boolean;
|
|
291
|
+
}
|
|
178
292
|
return { question, warnings };
|
|
179
293
|
}
|
|
180
294
|
|
|
181
295
|
function readDisplayMode(value: unknown): "overlay" | "inline" {
|
|
182
296
|
if (value === undefined) return "overlay";
|
|
183
|
-
if (value !== "overlay" && value !== "inline")
|
|
184
|
-
throw new AskUserValidationError('displayMode must be "overlay" or "inline".');
|
|
185
|
-
}
|
|
297
|
+
if (value !== "overlay" && value !== "inline") throw new AskUserValidationError('displayMode must be "overlay" or "inline".');
|
|
186
298
|
return value;
|
|
187
299
|
}
|
|
188
300
|
|
|
189
301
|
function readTimeoutPerQuestionMs(value: unknown): number {
|
|
190
302
|
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
|
-
}
|
|
303
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new AskUserValidationError("timeoutPerQuestionMs must be a non-negative finite number.");
|
|
194
304
|
return Math.floor(value);
|
|
195
305
|
}
|
|
196
306
|
|
|
197
|
-
/** Normalize and validate a request against the
|
|
307
|
+
/** Normalize and validate a request against the exact tool/direct-call contract. */
|
|
198
308
|
export function normalizeAskUserRequest(raw: unknown): { request: NormalizedRequest; warnings: string[] } {
|
|
199
309
|
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
|
-
}
|
|
310
|
+
assertKnownKeys(raw, REQUEST_FIELDS, "Request");
|
|
205
311
|
const rawQuestions = raw.questions;
|
|
206
312
|
if (!Array.isArray(rawQuestions)) throw new AskUserValidationError("Missing the questions array.");
|
|
207
313
|
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
|
-
}
|
|
314
|
+
if (rawQuestions.length > MAX_QUESTIONS) throw new AskUserValidationError(`At most ${MAX_QUESTIONS} questions; received ${rawQuestions.length}.`);
|
|
211
315
|
|
|
316
|
+
if (raw.allowFreeText !== undefined && typeof raw.allowFreeText !== "boolean") {
|
|
317
|
+
throw new AskUserValidationError("allowFreeText must be a boolean.");
|
|
318
|
+
}
|
|
319
|
+
const allowFreeText = raw.allowFreeText !== false;
|
|
212
320
|
const warnings: string[] = [];
|
|
213
321
|
const usedIds = new Set<string>();
|
|
214
322
|
const questions: NormalizedQuestion[] = [];
|
|
215
323
|
rawQuestions.forEach((rawQuestion, index) => {
|
|
216
|
-
const
|
|
217
|
-
warnings.push(...
|
|
218
|
-
|
|
324
|
+
const parsed = readQuestion(rawQuestion, index);
|
|
325
|
+
warnings.push(...parsed.warnings);
|
|
326
|
+
const question = parsed.question;
|
|
219
327
|
let id = question.id ?? "";
|
|
220
328
|
if (id === "" || usedIds.has(id)) {
|
|
221
329
|
let candidate = `q${index + 1}`;
|
|
222
330
|
let suffix = 1;
|
|
223
|
-
while (usedIds.has(candidate)) {
|
|
224
|
-
candidate = `q${index + 1}_${suffix++}`;
|
|
225
|
-
}
|
|
331
|
+
while (usedIds.has(candidate)) candidate = `q${index + 1}_${suffix++}`;
|
|
226
332
|
id = candidate;
|
|
227
333
|
}
|
|
228
334
|
usedIds.add(id);
|
|
229
|
-
|
|
230
335
|
const normalized: NormalizedQuestion = {
|
|
231
336
|
id,
|
|
232
337
|
index,
|
|
233
338
|
title: question.title,
|
|
234
339
|
kind: question.kind,
|
|
235
|
-
options: question.options
|
|
236
|
-
hasDefault: question.
|
|
340
|
+
options: question.options,
|
|
341
|
+
hasDefault: question.hasDefault,
|
|
342
|
+
fields: question.fields,
|
|
237
343
|
};
|
|
238
344
|
if (question.prompt !== undefined) normalized.prompt = question.prompt;
|
|
239
345
|
if (question.default !== undefined) normalized.default = question.default;
|
|
346
|
+
if (question.kind === "single" || question.kind === "multi") {
|
|
347
|
+
normalized.allowFreeText = allowFreeText;
|
|
348
|
+
if (!allowFreeText && question.hasDefault && !question.options.some((option) => option.label === String(question.default).trim())) {
|
|
349
|
+
throw new AskUserValidationError(`Question ${index + 1} default must match an option label when allowFreeText is false.`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
240
352
|
questions.push(normalized);
|
|
241
353
|
});
|
|
242
354
|
|
|
243
|
-
const header = optionalText(raw.header, "header", LIMITS.header);
|
|
244
|
-
const displayMode = readDisplayMode(raw.displayMode);
|
|
245
355
|
const timeoutPerQuestionMs = readTimeoutPerQuestionMs(raw.timeoutPerQuestionMs);
|
|
246
|
-
|
|
247
356
|
const request: NormalizedRequest = {
|
|
248
357
|
questions,
|
|
249
|
-
|
|
358
|
+
allowFreeText,
|
|
359
|
+
displayMode: readDisplayMode(raw.displayMode),
|
|
250
360
|
timeoutPerQuestionMs,
|
|
251
361
|
totalTimeoutMs: timeoutPerQuestionMs * questions.length,
|
|
252
362
|
};
|
|
363
|
+
const header = optionalText(raw.header, "header", LIMITS.header);
|
|
253
364
|
if (header !== undefined) request.header = header;
|
|
254
365
|
return { request, warnings };
|
|
255
366
|
}
|