@itc-steve/pi-ask-complete 0.1.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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * ask_user tool — multi-question bottom panel the LLM can call.
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ AskUserParams,
8
+ AskUserResultView,
9
+ buildAskUserJsonPayload,
10
+ runAskUserPanel,
11
+ sanitizeQuestions,
12
+ type AskUserResult,
13
+ } from "./ask-user-panel.ts";
14
+ import { errorResult } from "./helpers.ts";
15
+
16
+ const ASK_USER_DESCRIPTION =
17
+ "Ask the user one or more questions with selectable options. " +
18
+ "ALWAYS prefer this tool over plain-text multiple-choice questions when you need a decision, preference, or confirmation. " +
19
+ "Supports single-select and multi-select. Every question includes a 'Type something.' row for free-form answers. " +
20
+ "Use for clarifying requirements, picking between distinct paths, or confirming decisions. " +
21
+ "Pass 2–4 options per question with short labels and descriptions. " +
22
+ "Result JSON: { cancelled, answers: [{ tab, answer|custom|answers|skipped }], message? }.";
23
+
24
+ export function registerAskUser(pi: ExtensionAPI): void {
25
+ pi.registerTool({
26
+ name: "ask_user",
27
+ label: "Ask User",
28
+ description: ASK_USER_DESCRIPTION,
29
+ parameters: AskUserParams,
30
+ executionMode: "sequential",
31
+
32
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
33
+ if (!ctx.hasUI) {
34
+ return errorResult("Error: UI not available (running in non-interactive mode)");
35
+ }
36
+ if (params.questions.length === 0) {
37
+ return errorResult("Error: No questions provided");
38
+ }
39
+
40
+ const questions = sanitizeQuestions(params.questions);
41
+ const result = await runAskUserPanel(ctx, questions);
42
+ const payload = buildAskUserJsonPayload(questions, result);
43
+
44
+ return {
45
+ content: [{ type: "text", text: JSON.stringify(payload) }],
46
+ details: result,
47
+ };
48
+ },
49
+
50
+ renderResult(result, options, theme, context) {
51
+ const questions = (context.args?.questions ?? []).map(
52
+ (question: { header: string; tab: string }, index: number) => ({
53
+ ...question,
54
+ id: `question-${index + 1}`,
55
+ }),
56
+ );
57
+ const raw = (result.details ?? {}) as Partial<AskUserResult>;
58
+ const details: AskUserResult = {
59
+ questions: raw.questions ?? [],
60
+ answers: raw.answers ?? [],
61
+ cancelled: raw.cancelled ?? true,
62
+ message: raw.message,
63
+ };
64
+ const comp =
65
+ context.lastComponent instanceof AskUserResultView
66
+ ? context.lastComponent
67
+ : new AskUserResultView(questions, details, theme);
68
+ comp.setExpanded(options.expanded);
69
+ return comp;
70
+ },
71
+ });
72
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Extract the base command name used for permanent whitelist entries.
3
+ *
4
+ * Examples:
5
+ * grep -f "Hi" → grep
6
+ * /usr/bin/grep foo → grep
7
+ * sudo apt install x → apt
8
+ * FOO=1 BAR=2 ls -la → ls
9
+ * env FOO=1 npm test → npm
10
+ * strace -f rm -rf x → rm
11
+ */
12
+
13
+ /** True when the command is invoked via sudo/doas (bash should use sudo_run instead). */
14
+ export function isSudoPrefixed(command: string): boolean {
15
+ const s = command.trim();
16
+ if (!s) return false;
17
+ // sudo / doas, optional long flags before the real command
18
+ return /^(sudo|doas)(\s|$)/i.test(s);
19
+ }
20
+
21
+ /** Strip a leading sudo/doas (+ common flags) so rules match the real binary. */
22
+ export function stripSudoPrefix(command: string): string {
23
+ let s = command.trim();
24
+ // sudo -n -u root pacman … → pacman …
25
+ s = s.replace(/^(sudo|doas)(\s+(-[A-Za-z]+|--\S+))*(?=\s|$)/i, "").trim();
26
+ return s;
27
+ }
28
+
29
+ /**
30
+ * Exec wrappers whose own allow must never stand for the inner binary.
31
+ * Keep in sync with stripWrappers.
32
+ */
33
+ const WRAPPERS = new Set([
34
+ "command",
35
+ "builtin",
36
+ "time",
37
+ "nohup",
38
+ "nice",
39
+ "env",
40
+ "strace",
41
+ "ltrace",
42
+ "timeout",
43
+ "xargs",
44
+ "stdbuf",
45
+ "setsid",
46
+ "chroot",
47
+ "unshare",
48
+ ]);
49
+
50
+ /** Long opts that take a separate argument for the wrappers above. */
51
+ const WRAPPER_LONG_VALUE = new Set([
52
+ "user",
53
+ "unset",
54
+ "chdir",
55
+ "split-string",
56
+ "arg-file",
57
+ "delimiter",
58
+ "replace",
59
+ "signal",
60
+ "kill-after",
61
+ "max-procs",
62
+ "max-args",
63
+ "max-chars",
64
+ ]);
65
+
66
+ /** Short opts that take a separate argument (`-u NAME`, `-e EXPR`, …). */
67
+ const WRAPPER_SHORT_VALUE = new Set([
68
+ "u",
69
+ "e",
70
+ "p",
71
+ "s",
72
+ "k",
73
+ "n",
74
+ "C",
75
+ "I",
76
+ "E",
77
+ "L",
78
+ "P",
79
+ "S",
80
+ "o", // stdbuf -o MODE
81
+ ]);
82
+
83
+ /** Drop leading wrapper flags (`-f`, `-i`, `-u NAME`, `--flag`, timeout duration). */
84
+ function skipWrapperFlags(rest: string): string {
85
+ for (let n = 0; n < 24 && rest; n++) {
86
+ if (rest === "--") return "";
87
+ if (rest.startsWith("-- ")) return rest.slice(3).trim();
88
+
89
+ if (rest.startsWith("--")) {
90
+ const m = rest.match(/^--([^=\s]+)(?:=(\S+))?(?:\s+|$)([\s\S]*)/);
91
+ if (!m) break;
92
+ rest = (m[3] ?? "").trim();
93
+ // --user NAME (value not glued with =)
94
+ if (m[2] === undefined && WRAPPER_LONG_VALUE.has(m[1]!) && rest && !rest.startsWith("-")) {
95
+ rest = rest.replace(/^\S+\s*/, "").trim();
96
+ }
97
+ continue;
98
+ }
99
+
100
+ if (rest[0] === "-" && rest[1] && rest[1] !== "-") {
101
+ // -f / -i / -u NAME / -e=expr
102
+ const m = rest.match(/^-([A-Za-z0-9]+)(?:=(\S+))?(?:\s+|$)([\s\S]*)/);
103
+ if (!m) break;
104
+ const flags = m[1]!;
105
+ rest = (m[3] ?? "").trim();
106
+ if (m[2] !== undefined) continue; // -e=expr already consumed
107
+ // single short opt that takes a value: -u NAME
108
+ if (
109
+ flags.length === 1 &&
110
+ WRAPPER_SHORT_VALUE.has(flags) &&
111
+ rest &&
112
+ !rest.startsWith("-")
113
+ ) {
114
+ rest = rest.replace(/^\S+\s*/, "").trim();
115
+ }
116
+ continue;
117
+ }
118
+
119
+ // timeout duration: 5 / 5s / 1m
120
+ if (/^\d/.test(rest)) {
121
+ rest = rest.replace(/^\S+\s*/, "").trim();
122
+ continue;
123
+ }
124
+ break;
125
+ }
126
+ return rest;
127
+ }
128
+
129
+ /**
130
+ * True when command starts with a wrapper that has trailing tokens.
131
+ * Combined with stripWrappers() === "", means the inner binary is unresolvable.
132
+ */
133
+ export function wrapperHasArgs(command: string): boolean {
134
+ let s = command.trim();
135
+ s = s.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, "").trim();
136
+ if (!s) return false;
137
+ const m = s.match(/^(\S+)(?:\s+(\S+))?/);
138
+ if (!m?.[2]) return false;
139
+ const tok = m[1]!.replace(/^['"]|['"]$/g, "");
140
+ const bin = (tok.split(/[/\\]/).pop() ?? tok).toLowerCase();
141
+ return WRAPPERS.has(bin);
142
+ }
143
+
144
+ /**
145
+ * Peel leading ENV=value assignments + exec wrappers (env/strace/timeout/…).
146
+ * Returns the inner command, or "" when only a bare wrapper remains.
147
+ * Callers must treat "" + a wrapper prefix as unresolvable (ask, never allow).
148
+ */
149
+ export function stripWrappers(command: string): string {
150
+ let s = command.trim();
151
+ if (!s) return "";
152
+
153
+ for (let i = 0; i < 8; i++) {
154
+ // FOO=1 BAR=2 cmd → cmd
155
+ s = s
156
+ .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, "")
157
+ .trim();
158
+ if (!s) return "";
159
+
160
+ const m = s.match(/^(\S+)(?:\s+([\s\S]*))?$/);
161
+ if (!m) return s;
162
+ const tok = m[1]!.replace(/^['"]|['"]$/g, "");
163
+ const bin = (tok.split(/[/\\]/).pop() ?? tok).toLowerCase();
164
+ if (!WRAPPERS.has(bin)) return s; // real binary — stop
165
+
166
+ const rest = skipWrapperFlags((m[2] ?? "").trim());
167
+ if (!rest) return ""; // bare wrapper / flags only
168
+ s = rest;
169
+ }
170
+ return s.trim();
171
+ }
172
+
173
+ export function baseCommand(command: string): string {
174
+ let s = stripSudoPrefix(command.trim());
175
+ if (!s) return "";
176
+
177
+ // Resolve through wrappers so `strace -f rm` → rm (not strace).
178
+ // Bare `env` peels to "" — fall back so the wrapper name itself is the base.
179
+ s = stripWrappers(s) || s;
180
+
181
+ // If the user wrote a pipeline/list, whitelist against the first segment.
182
+ const first = s.split(/[|;&\n]/)[0]?.trim() ?? s;
183
+ const token = first.split(/\s+/)[0] ?? "";
184
+ if (!token) return "";
185
+
186
+ // Drop surrounding quotes and path prefix.
187
+ const unquoted = token.replace(/^['"]|['"]$/g, "");
188
+ const base = unquoted.split(/[/\\]/).pop() ?? unquoted;
189
+ return base;
190
+ }
191
+
192
+ /** Keywords / non-binaries that must never become permanent allow keys. */
193
+ const BLOCKED_BASH_KEYS = new Set([
194
+ // shell grammar
195
+ "if",
196
+ "then",
197
+ "else",
198
+ "elif",
199
+ "fi",
200
+ "for",
201
+ "while",
202
+ "until",
203
+ "do",
204
+ "done",
205
+ "case",
206
+ "esac",
207
+ "function",
208
+ "select",
209
+ "coproc",
210
+ "in",
211
+ // common leaks from node/python heredoc bodies
212
+ "const",
213
+ "let",
214
+ "var",
215
+ "return",
216
+ "class",
217
+ "import",
218
+ "from",
219
+ "def",
220
+ ]);
221
+
222
+ /**
223
+ * True when a bash allow key is safe to write to permission.json.
224
+ * Permanent allow only stores simple binary names (python3, mkfs.ext4) — never
225
+ * prose fragments, JS lines, or shell keywords that leaked from bad splits.
226
+ */
227
+ export function isPersistableBashKey(key: string): boolean {
228
+ if (!key || key.length > 64) return false;
229
+ if (BLOCKED_BASH_KEYS.has(key)) return false;
230
+ // Simple binary: rg, python3, docker-compose
231
+ if (/^[A-Za-z_][A-Za-z0-9_+-]*$/.test(key)) return true;
232
+ // Dotted binaries only for known families (mkfs.ext4, python3.12) — not console.log
233
+ if (/^(mkfs|fsck|python|pip|node)[0-9]*\.[A-Za-z0-9_+-]+$/.test(key)) return true;
234
+ return false;
235
+ }
236
+
237
+ /** True when `command` is covered by a whitelist entry (base name or exact). */
238
+ export function commandMatchesWhitelist(
239
+ command: string,
240
+ allowed: ReadonlySet<string> | readonly string[],
241
+ ): boolean {
242
+ const set = allowed instanceof Set ? allowed : new Set(allowed);
243
+ if (set.size === 0) return false;
244
+ const full = command.trim();
245
+ if (set.has(full)) return true;
246
+ const base = baseCommand(command);
247
+ return base !== "" && set.has(base);
248
+ }
249
+
250
+ /**
251
+ * Strip noise so rule patterns match the logical command.
252
+ * Today: git globals (`-C`, `-c`, `--git-dir`, …) so `git status*` matches
253
+ * `git -C /path status`.
254
+ *
255
+ * ponytail: regex strip of known globals; quoted paths with spaces not handled.
256
+ */
257
+ export function normalizeCommandForMatch(command: string): string {
258
+ const trimmed = command.trim();
259
+ if (!trimmed) return trimmed;
260
+
261
+ // First token's basename is `git` (covers git, /usr/bin/git, ./git).
262
+ // Always rewrite to bare `git` so patterns like `git status*` match.
263
+ const tok = trimmed.match(/^\S+/);
264
+ if (!tok) return trimmed;
265
+ const bin = (tok[0]!.split(/[/\\]/).pop() ?? tok[0]!).replace(/^['"]|['"]$/g, "");
266
+ if (!/^git$/i.test(bin)) return trimmed;
267
+
268
+ let rest = trimmed.slice(tok[0]!.length).replace(/^\s+/, "");
269
+
270
+ // Repeatedly peel one global option from the front until the subcommand.
271
+ // Options that take a value: -C path, -c key=val, --git-dir[=]path, …
272
+ const withVal =
273
+ /^(?:-C|--git-dir|--work-tree|--namespace|--config-env|--exec-path)(?:=|\s+)\S+\s*/;
274
+ const shortC = /^-c\s+\S+\s*/;
275
+ const flagOnly =
276
+ /^(?:-p|--paginate|-P|--no-pager|--bare|--no-replace-objects|--no-optional-locks|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs)\s+/;
277
+
278
+ for (let i = 0; i < 16; i++) {
279
+ let next = rest.replace(withVal, "").replace(shortC, "").replace(flagOnly, "");
280
+ // --exec-path with no value (prints path; rare in agent cmds)
281
+ next = next.replace(/^--exec-path\s+/, "");
282
+ if (next === rest) break;
283
+ rest = next;
284
+ }
285
+
286
+ return rest ? `git ${rest}`.trim() : "git";
287
+ }