@ryan_nookpi/pi-extension-ask-user-question 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/coerce.test.ts +121 -0
- package/coerce.ts +121 -0
- package/index.test.ts +54 -0
- package/index.ts +13 -7
- package/package.json +35 -25
package/README.md
CHANGED
|
@@ -88,3 +88,21 @@ pi install npm:@ryan_nookpi/pi-extension-ask-user-question
|
|
|
88
88
|
## 반환 형태
|
|
89
89
|
|
|
90
90
|
응답은 각 질문의 `id` 기준으로 정리되어 반환됩니다. 취소 시에는 `cancelled: true`가 내려오고, 완료 시에는 질문별 답변 목록이 포함됩니다.
|
|
91
|
+
|
|
92
|
+
## 허용되는 느슨한 입력 형태
|
|
93
|
+
|
|
94
|
+
위 스키마가 권장 형식이지만, 다음과 같은 변형 입력도 자동으로 정규화해서 처리합니다.
|
|
95
|
+
|
|
96
|
+
- `questions`를 JSON 문자열로 직렬화해 전달: `{ "questions": "[ ... ]" }`
|
|
97
|
+
- 질문 프롬프트를 `prompt` 대신 `question` 필드로 전달
|
|
98
|
+
- `options`를 `{ value, label }` 객체가 아닌 문자열 배열로 전달 (동일 값으로 승격)
|
|
99
|
+
- `options`를 JSON 문자열로 직렬화해 전달
|
|
100
|
+
- `id` 생략 시 순서대로 `q1`, `q2`, ...로 자동 부여
|
|
101
|
+
|
|
102
|
+
예:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"questions": "[{\"type\": \"radio\", \"question\": \"extension 이름\", \"options\": [\"claude-code-use\", \"pi-claude-code-use\"], \"allowOther\": true}]"
|
|
107
|
+
}
|
|
108
|
+
```
|
package/coerce.test.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { coerceAskUserQuestionParams } from "./coerce.ts";
|
|
4
|
+
|
|
5
|
+
describe("coerceAskUserQuestionParams", () => {
|
|
6
|
+
it("구조화된 파라미터를 그대로 보존한다", () => {
|
|
7
|
+
const input = {
|
|
8
|
+
title: "Title",
|
|
9
|
+
description: "Desc",
|
|
10
|
+
questions: [
|
|
11
|
+
{
|
|
12
|
+
id: "env",
|
|
13
|
+
type: "radio",
|
|
14
|
+
prompt: "어느 환경?",
|
|
15
|
+
options: [
|
|
16
|
+
{ value: "staging", label: "스테이징" },
|
|
17
|
+
{ value: "prod", label: "프로덕션", description: "실서비스" },
|
|
18
|
+
],
|
|
19
|
+
allowOther: false,
|
|
20
|
+
required: true,
|
|
21
|
+
default: "staging",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const result = coerceAskUserQuestionParams(input);
|
|
27
|
+
expect(result).toEqual(input);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("JSON 문자열로 전달된 questions를 파싱한다", () => {
|
|
31
|
+
const payload = {
|
|
32
|
+
questions: JSON.stringify([
|
|
33
|
+
{
|
|
34
|
+
type: "radio",
|
|
35
|
+
question: "파일 배치 구조",
|
|
36
|
+
options: ["directory", "single file", "single file + flat"],
|
|
37
|
+
allowOther: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
type: "checkbox",
|
|
41
|
+
question: "포함 여부",
|
|
42
|
+
options: ["LICENSE", "author attribution"],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: "text",
|
|
46
|
+
question: "추가 메모",
|
|
47
|
+
placeholder: "선택 사항",
|
|
48
|
+
required: false,
|
|
49
|
+
},
|
|
50
|
+
]),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const result = coerceAskUserQuestionParams(payload);
|
|
54
|
+
|
|
55
|
+
expect(result.questions).toHaveLength(3);
|
|
56
|
+
expect(result.questions[0]).toEqual({
|
|
57
|
+
id: "q1",
|
|
58
|
+
type: "radio",
|
|
59
|
+
prompt: "파일 배치 구조",
|
|
60
|
+
options: [
|
|
61
|
+
{ value: "directory", label: "directory" },
|
|
62
|
+
{ value: "single file", label: "single file" },
|
|
63
|
+
{ value: "single file + flat", label: "single file + flat" },
|
|
64
|
+
],
|
|
65
|
+
allowOther: true,
|
|
66
|
+
});
|
|
67
|
+
expect(result.questions[1].id).toBe("q2");
|
|
68
|
+
expect(result.questions[1].type).toBe("checkbox");
|
|
69
|
+
expect(result.questions[1].options).toEqual([
|
|
70
|
+
{ value: "LICENSE", label: "LICENSE" },
|
|
71
|
+
{ value: "author attribution", label: "author attribution" },
|
|
72
|
+
]);
|
|
73
|
+
expect(result.questions[2]).toEqual({
|
|
74
|
+
id: "q3",
|
|
75
|
+
type: "text",
|
|
76
|
+
prompt: "추가 메모",
|
|
77
|
+
placeholder: "선택 사항",
|
|
78
|
+
required: false,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("question 별칭과 options JSON 문자열을 지원한다", () => {
|
|
83
|
+
const result = coerceAskUserQuestionParams({
|
|
84
|
+
questions: [
|
|
85
|
+
{
|
|
86
|
+
type: "radio",
|
|
87
|
+
question: "extension 이름",
|
|
88
|
+
options: JSON.stringify(["claude-code-use", "pi-claude-code-use"]),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.questions[0].prompt).toBe("extension 이름");
|
|
94
|
+
expect(result.questions[0].options).toEqual([
|
|
95
|
+
{ value: "claude-code-use", label: "claude-code-use" },
|
|
96
|
+
{ value: "pi-claude-code-use", label: "pi-claude-code-use" },
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("잘못된 입력을 조용히 걸러낸다", () => {
|
|
101
|
+
const result = coerceAskUserQuestionParams({
|
|
102
|
+
questions: [
|
|
103
|
+
null,
|
|
104
|
+
{ type: "radio" }, // prompt 없음 -> 제외
|
|
105
|
+
{ type: "unknown", prompt: "?" }, // 잘못된 type -> 제외
|
|
106
|
+
{ type: "text", prompt: "OK" },
|
|
107
|
+
"not-an-object",
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.questions).toHaveLength(1);
|
|
112
|
+
expect(result.questions[0]).toMatchObject({ id: "q4", type: "text", prompt: "OK" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("questions 자체가 누락되거나 비-객체 입력이면 빈 배열을 돌려준다", () => {
|
|
116
|
+
expect(coerceAskUserQuestionParams(null).questions).toEqual([]);
|
|
117
|
+
expect(coerceAskUserQuestionParams(undefined).questions).toEqual([]);
|
|
118
|
+
expect(coerceAskUserQuestionParams({ questions: "not-json" }).questions).toEqual([]);
|
|
119
|
+
expect(coerceAskUserQuestionParams({ questions: '{"not":"array"}' }).questions).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
package/coerce.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { AskUserQuestionParamsInput, Question, QuestionOption } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 느슨한 입력을 정규화된 파라미터로 변환한다.
|
|
5
|
+
*
|
|
6
|
+
* 허용하는 변형:
|
|
7
|
+
* - `questions`가 JSON 문자열이면 파싱한다.
|
|
8
|
+
* - 질문의 `question` 필드를 `prompt` 별칭으로 받는다.
|
|
9
|
+
* - `options`가 문자열 배열이면 `{ value, label }` 형태로 승격한다.
|
|
10
|
+
* - `options`가 JSON 문자열이면 파싱한다.
|
|
11
|
+
* - `id`가 없으면 `q1`, `q2` 형태로 자동 생성한다.
|
|
12
|
+
*/
|
|
13
|
+
export function coerceAskUserQuestionParams(raw: unknown): AskUserQuestionParamsInput {
|
|
14
|
+
if (raw == null || typeof raw !== "object") {
|
|
15
|
+
return { questions: [] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const record = raw as Record<string, unknown>;
|
|
19
|
+
const title = typeof record.title === "string" ? record.title : undefined;
|
|
20
|
+
const description = typeof record.description === "string" ? record.description : undefined;
|
|
21
|
+
const questions = coerceQuestionList(record.questions);
|
|
22
|
+
|
|
23
|
+
return { title, description, questions };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function coerceQuestionList(value: unknown): Question[] {
|
|
27
|
+
const list = parseMaybeJsonArray(value);
|
|
28
|
+
if (!list) return [];
|
|
29
|
+
|
|
30
|
+
return list
|
|
31
|
+
.map((item, index) => coerceQuestion(item, index))
|
|
32
|
+
.filter((question): question is Question => question != null);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function coerceQuestion(value: unknown, index: number): Question | null {
|
|
36
|
+
if (value == null || typeof value !== "object") return null;
|
|
37
|
+
const record = value as Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
const type = coerceType(record.type);
|
|
40
|
+
if (!type) return null;
|
|
41
|
+
|
|
42
|
+
const prompt = coerceString(record.prompt) ?? coerceString(record.question);
|
|
43
|
+
if (!prompt) return null;
|
|
44
|
+
|
|
45
|
+
const id = coerceString(record.id) ?? `q${index + 1}`;
|
|
46
|
+
const question: Question = { id, type, prompt };
|
|
47
|
+
|
|
48
|
+
const label = coerceString(record.label);
|
|
49
|
+
if (label) question.label = label;
|
|
50
|
+
|
|
51
|
+
const options = coerceOptions(record.options);
|
|
52
|
+
if (options.length > 0) question.options = options;
|
|
53
|
+
|
|
54
|
+
if (typeof record.allowOther === "boolean") question.allowOther = record.allowOther;
|
|
55
|
+
if (typeof record.required === "boolean") question.required = record.required;
|
|
56
|
+
|
|
57
|
+
const placeholder = coerceString(record.placeholder);
|
|
58
|
+
if (placeholder) question.placeholder = placeholder;
|
|
59
|
+
|
|
60
|
+
const defaultValue = coerceDefault(record.default);
|
|
61
|
+
if (defaultValue !== undefined) question.default = defaultValue;
|
|
62
|
+
|
|
63
|
+
return question;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function coerceOptions(value: unknown): QuestionOption[] {
|
|
67
|
+
const list = parseMaybeJsonArray(value);
|
|
68
|
+
if (!list) return [];
|
|
69
|
+
|
|
70
|
+
return list.map((item) => coerceOption(item)).filter((option): option is QuestionOption => option != null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function coerceOption(value: unknown): QuestionOption | null {
|
|
74
|
+
if (typeof value === "string") {
|
|
75
|
+
const trimmed = value.trim();
|
|
76
|
+
if (!trimmed) return null;
|
|
77
|
+
return { value: trimmed, label: trimmed };
|
|
78
|
+
}
|
|
79
|
+
if (value == null || typeof value !== "object") return null;
|
|
80
|
+
const record = value as Record<string, unknown>;
|
|
81
|
+
const rawValue = coerceString(record.value) ?? coerceString(record.label);
|
|
82
|
+
const rawLabel = coerceString(record.label) ?? coerceString(record.value);
|
|
83
|
+
if (!rawValue || !rawLabel) return null;
|
|
84
|
+
const option: QuestionOption = { value: rawValue, label: rawLabel };
|
|
85
|
+
const description = coerceString(record.description);
|
|
86
|
+
if (description) option.description = description;
|
|
87
|
+
return option;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function coerceType(value: unknown): "radio" | "checkbox" | "text" | null {
|
|
91
|
+
if (value === "radio" || value === "checkbox" || value === "text") return value;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function coerceString(value: unknown): string | undefined {
|
|
96
|
+
if (typeof value !== "string") return undefined;
|
|
97
|
+
const trimmed = value.trim();
|
|
98
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function coerceDefault(value: unknown): string | string[] | undefined {
|
|
102
|
+
if (typeof value === "string") return value;
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
const strings = value.filter((item): item is string => typeof item === "string");
|
|
105
|
+
return strings.length > 0 ? strings : undefined;
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseMaybeJsonArray(value: unknown): unknown[] | null {
|
|
111
|
+
if (Array.isArray(value)) return value;
|
|
112
|
+
if (typeof value !== "string") return null;
|
|
113
|
+
const trimmed = value.trim();
|
|
114
|
+
if (!trimmed) return null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(trimmed);
|
|
117
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/index.test.ts
CHANGED
|
@@ -42,11 +42,65 @@ describe("ask-user-question extension", () => {
|
|
|
42
42
|
hasUI: true,
|
|
43
43
|
ui: { custom: vi.fn() },
|
|
44
44
|
} as unknown as ExtensionContext);
|
|
45
|
+
const invalidQuestions = await tool.execute(
|
|
46
|
+
"call-2b",
|
|
47
|
+
{ title: "Title", questions: "not-json" },
|
|
48
|
+
undefined,
|
|
49
|
+
undefined,
|
|
50
|
+
{ hasUI: true, ui: { custom: vi.fn() } } as unknown as ExtensionContext,
|
|
51
|
+
);
|
|
45
52
|
|
|
46
53
|
expect(noUi).toMatchObject({
|
|
47
54
|
content: [{ type: "text", text: "오류: UI를 사용할 수 없습니다. 현재 비대화형 모드에서 실행 중입니다." }],
|
|
48
55
|
});
|
|
49
56
|
expect(noQuestions).toMatchObject({ content: [{ type: "text", text: "오류: 질문이 제공되지 않았습니다." }] });
|
|
57
|
+
expect(invalidQuestions).toMatchObject({ content: [{ type: "text", text: "오류: 질문이 제공되지 않았습니다." }] });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("JSON 문자열로 직렬화된 questions도 폼 실행 경로로 진입한다", async () => {
|
|
61
|
+
const apiMock = createExtensionApiMock();
|
|
62
|
+
askUserQuestionExtension(apiMock.api);
|
|
63
|
+
const tool = apiMock.getTool("ask_user_question");
|
|
64
|
+
if (!tool.execute) throw new Error("execute is missing");
|
|
65
|
+
|
|
66
|
+
const customMock = vi.fn(async () => ({
|
|
67
|
+
title: undefined,
|
|
68
|
+
questions: normalizeQuestions([
|
|
69
|
+
{
|
|
70
|
+
id: "q1",
|
|
71
|
+
type: "radio",
|
|
72
|
+
prompt: "extension 이름",
|
|
73
|
+
options: [
|
|
74
|
+
{ value: "claude-code-use", label: "claude-code-use" },
|
|
75
|
+
{ value: "pi-claude-code-use", label: "pi-claude-code-use" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
]),
|
|
79
|
+
answers: [{ id: "q1", type: "radio" as const, value: "claude-code-use", wasCustom: false }],
|
|
80
|
+
cancelled: false,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const serializedResult = await tool.execute(
|
|
84
|
+
"call-json",
|
|
85
|
+
{
|
|
86
|
+
questions: JSON.stringify([
|
|
87
|
+
{
|
|
88
|
+
type: "radio",
|
|
89
|
+
question: "extension 이름",
|
|
90
|
+
options: ["claude-code-use", "pi-claude-code-use"],
|
|
91
|
+
allowOther: true,
|
|
92
|
+
},
|
|
93
|
+
]),
|
|
94
|
+
},
|
|
95
|
+
undefined,
|
|
96
|
+
undefined,
|
|
97
|
+
{ hasUI: true, ui: { custom: customMock } } as unknown as ExtensionContext,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(customMock).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(serializedResult).toMatchObject({
|
|
102
|
+
content: [{ type: "text", text: "Q1: claude-code-use" }],
|
|
103
|
+
});
|
|
50
104
|
});
|
|
51
105
|
|
|
52
106
|
it("returns cancelled and successful execution results", async () => {
|
package/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
|
|
3
|
+
import { coerceAskUserQuestionParams } from "./coerce.ts";
|
|
3
4
|
import { runAskUserQuestionForm } from "./form-ui.ts";
|
|
4
5
|
import {
|
|
5
6
|
buildRenderCallText,
|
|
@@ -44,7 +45,14 @@ function buildSuccessResponse(result: FormResult) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export type { AskUserQuestionParamsInput, FormResult, Question };
|
|
47
|
-
export {
|
|
48
|
+
export {
|
|
49
|
+
AskUserQuestionParams,
|
|
50
|
+
buildRenderCallText,
|
|
51
|
+
buildRenderResultText,
|
|
52
|
+
coerceAskUserQuestionParams,
|
|
53
|
+
errorResult,
|
|
54
|
+
normalizeQuestions,
|
|
55
|
+
};
|
|
48
56
|
|
|
49
57
|
export default function askUserQuestion(pi: ExtensionAPI) {
|
|
50
58
|
pi.registerTool({
|
|
@@ -59,12 +67,12 @@ export default function askUserQuestion(pi: ExtensionAPI) {
|
|
|
59
67
|
return errorResult("오류: UI를 사용할 수 없습니다. 현재 비대화형 모드에서 실행 중입니다.");
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
const params = rawParams
|
|
70
|
+
const params = coerceAskUserQuestionParams(rawParams);
|
|
63
71
|
if (!params.questions.length) {
|
|
64
72
|
return errorResult("오류: 질문이 제공되지 않았습니다.");
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
const questions = normalizeQuestions(params.questions
|
|
75
|
+
const questions = normalizeQuestions(params.questions);
|
|
68
76
|
const result = await runAskUserQuestionForm(
|
|
69
77
|
ctx,
|
|
70
78
|
{ title: params.title, description: params.description },
|
|
@@ -73,10 +81,8 @@ export default function askUserQuestion(pi: ExtensionAPI) {
|
|
|
73
81
|
return result.cancelled ? buildCancelledResponse(result) : buildSuccessResponse(result);
|
|
74
82
|
},
|
|
75
83
|
renderCall(args, theme) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
theme,
|
|
79
|
-
);
|
|
84
|
+
const coerced = coerceAskUserQuestionParams(args);
|
|
85
|
+
return renderCall({ questions: coerced.questions, title: coerced.title }, theme);
|
|
80
86
|
},
|
|
81
87
|
renderResult(result, _options, theme) {
|
|
82
88
|
return renderResult(result as { content?: Array<{ type: string; text?: string }>; details?: FormResult }, theme);
|
package/package.json
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
2
|
+
"name": "@ryan_nookpi/pi-extension-ask-user-question",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "AskUserQuestion tool extension for pi.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/Jonghakseo/pi-extension.git",
|
|
9
|
+
"directory": "packages/ask-user-question"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Jonghakseo/pi-extension/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/Jonghakseo/pi-extension/tree/main/packages/ask-user-question#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
"*.ts",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./index.ts"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
30
|
+
"@mariozechner/pi-tui": "*",
|
|
31
|
+
"@sinclair/typebox": "*"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|