@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/index.ts +34 -0
- package/package.json +62 -0
- package/permission.json.example +299 -0
- package/src/ask-user-panel.ts +1335 -0
- package/src/ask-user.ts +72 -0
- package/src/base-command.ts +287 -0
- package/src/bash-scan.ts +394 -0
- package/src/describe-tool.ts +141 -0
- package/src/helpers.ts +149 -0
- package/src/herdr-attention.ts +69 -0
- package/src/permission-panel.ts +276 -0
- package/src/permission-store.ts +551 -0
- package/src/permission.ts +364 -0
- package/src/schema.ts +56 -0
- package/src/types.ts +114 -0
- package/src/wildcard.ts +102 -0
package/src/bash-scan.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic bash decomposition for the permission gate.
|
|
3
|
+
*
|
|
4
|
+
* Two jobs:
|
|
5
|
+
* - splitUnits: break a command line into individual command "units" so a rule
|
|
6
|
+
* lookup can require EVERY unit to be allowed (closes `ls; rm -rf x` bypass).
|
|
7
|
+
* Units include segments split on ; | && || & \n AND the contents of
|
|
8
|
+
* command substitution `$(...)` / backticks (closes `echo $(rm x)`).
|
|
9
|
+
* - pathArgs: pull path-like arguments (incl. redirect targets) out of a
|
|
10
|
+
* command so they can be checked against path deny rules
|
|
11
|
+
* (closes `cat .env` / `echo x > .env`).
|
|
12
|
+
*
|
|
13
|
+
* ponytail: regex heuristic, not a real shell parser. Misses full $VAR resolution
|
|
14
|
+
* and nested quote mazes. Unresolved globs/braces/$'' force ask at the store.
|
|
15
|
+
* Upgrade to a shell AST (mvdan-style) only if those vectors matter.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MAX_DEPTH = 6;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Blank out <<EOF … EOF heredoc bodies so their lines are not treated as units
|
|
22
|
+
* (node <<'EOF' / const / for / console.log were landing in permission.json).
|
|
23
|
+
*/
|
|
24
|
+
export function stripHeredocs(command: string): string {
|
|
25
|
+
// <<[-]? optional quotes WORD then body until a line that is exactly WORD
|
|
26
|
+
return command.replace(
|
|
27
|
+
/<<(-)?\s*(['"]?)(\w+)\2\r?\n[\s\S]*?\r?\n\3(?=\r?\n|$)/g,
|
|
28
|
+
" ",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Drop `# …` comments outside quotes so agent annotations don't become units
|
|
34
|
+
* (`# setup; then rm x` → no `#`/`then` bases from prose).
|
|
35
|
+
* ponytail: not a full lexer; # inside $'…' / unclosed quotes is best-effort.
|
|
36
|
+
*/
|
|
37
|
+
export function stripComments(command: string): string {
|
|
38
|
+
let out = "";
|
|
39
|
+
let quote: "'" | '"' | null = null;
|
|
40
|
+
for (let i = 0; i < command.length; i++) {
|
|
41
|
+
const c = command[i]!;
|
|
42
|
+
if (quote) {
|
|
43
|
+
out += c;
|
|
44
|
+
if (c === "\\" && quote === '"' && i + 1 < command.length) {
|
|
45
|
+
out += command[++i];
|
|
46
|
+
} else if (c === quote) {
|
|
47
|
+
quote = null;
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (c === "'" || c === '"') {
|
|
52
|
+
quote = c;
|
|
53
|
+
out += c;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Bash: # starts a comment only at a word boundary (not $# / ${#x} / foo#bar).
|
|
57
|
+
const prev = i === 0 ? "" : command[i - 1]!;
|
|
58
|
+
if (c === "#" && (i === 0 || /[\s;|&()]/.test(prev))) {
|
|
59
|
+
while (i + 1 < command.length && command[i + 1] !== "\n") i++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
out += c;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Split on shell control operators, but NOT inside quotes.
|
|
69
|
+
* Fixes `python3 -c "…\n…"` being shredded into fake units (and crashing the
|
|
70
|
+
* permission panel with a 20-base label).
|
|
71
|
+
*/
|
|
72
|
+
function splitSegments(command: string): string[] {
|
|
73
|
+
const parts: string[] = [];
|
|
74
|
+
let cur = "";
|
|
75
|
+
let quote: "'" | '"' | null = null;
|
|
76
|
+
for (let i = 0; i < command.length; i++) {
|
|
77
|
+
const c = command[i]!;
|
|
78
|
+
if (quote) {
|
|
79
|
+
cur += c;
|
|
80
|
+
if (c === "\\" && quote === '"' && i + 1 < command.length) {
|
|
81
|
+
cur += command[++i];
|
|
82
|
+
} else if (c === quote) {
|
|
83
|
+
quote = null;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (c === "'" || c === '"') {
|
|
88
|
+
quote = c;
|
|
89
|
+
cur += c;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// && or ||
|
|
93
|
+
if ((c === "&" || c === "|") && command[i + 1] === c) {
|
|
94
|
+
if (cur.trim()) parts.push(cur.trim());
|
|
95
|
+
cur = "";
|
|
96
|
+
i++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Redirects that contain & must not become split points:
|
|
100
|
+
// 2>&1 >&2 &>file &>>file
|
|
101
|
+
// (bare `cmd &` / `cmd1 & cmd2` still split — that's intentional).
|
|
102
|
+
if (c === "&") {
|
|
103
|
+
const prev = cur.length ? cur[cur.length - 1]! : "";
|
|
104
|
+
const next = command[i + 1] ?? "";
|
|
105
|
+
if (prev === ">" || next === ">") {
|
|
106
|
+
cur += c;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (c === ";" || c === "|" || c === "&" || c === "\n") {
|
|
111
|
+
if (cur.trim()) parts.push(cur.trim());
|
|
112
|
+
cur = "";
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
cur += c;
|
|
116
|
+
}
|
|
117
|
+
if (cur.trim()) parts.push(cur.trim());
|
|
118
|
+
return parts;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Extract `$(...)`, backtick, and process substitution <(...)/>(...) bodies.
|
|
122
|
+
* (one level; recursion + segment split handles nesting). */
|
|
123
|
+
function extractSubstitutions(command: string): string[] {
|
|
124
|
+
const out: string[] = [];
|
|
125
|
+
// $( ... )
|
|
126
|
+
const dollar = /\$\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g;
|
|
127
|
+
let m: RegExpExecArray | null;
|
|
128
|
+
while ((m = dollar.exec(command))) {
|
|
129
|
+
if (m[1]?.trim()) out.push(m[1].trim());
|
|
130
|
+
}
|
|
131
|
+
// backticks
|
|
132
|
+
const back = /`([^`]*)`/g;
|
|
133
|
+
while ((m = back.exec(command))) {
|
|
134
|
+
if (m[1]?.trim()) out.push(m[1].trim());
|
|
135
|
+
}
|
|
136
|
+
// process substitution <(cmd) and >(cmd) — C1 fix
|
|
137
|
+
const proc = /[<>]\(\s*([^()]*?(?:\([^()]*\)[^()]*?)*)\s*\)/g;
|
|
138
|
+
while ((m = proc.exec(command))) {
|
|
139
|
+
if (m[1]?.trim()) out.push(m[1].trim());
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* All command units in a line: each segment, plus every substitution body
|
|
146
|
+
* (recursively), each itself segment-split.
|
|
147
|
+
*/
|
|
148
|
+
export function splitUnits(command: string, depth = 0): string[] {
|
|
149
|
+
// Top-level only: drop comments + heredoc bodies before segmenting.
|
|
150
|
+
const cleaned =
|
|
151
|
+
depth === 0 ? stripComments(stripHeredocs(command)) : command;
|
|
152
|
+
const trimmed = cleaned.trim();
|
|
153
|
+
if (!trimmed || depth > MAX_DEPTH) return trimmed ? [trimmed] : [];
|
|
154
|
+
|
|
155
|
+
const units: string[] = [];
|
|
156
|
+
for (const seg of splitSegments(trimmed)) {
|
|
157
|
+
const subs = extractSubstitutions(seg);
|
|
158
|
+
// The segment with substitutions blanked out is still a real command to check.
|
|
159
|
+
const bare = seg
|
|
160
|
+
.replace(/\$\([^)]*\)/g, " ")
|
|
161
|
+
.replace(/`[^`]*`/g, " ")
|
|
162
|
+
.replace(/[<>]\([^)]*\)/g, " ")
|
|
163
|
+
.trim();
|
|
164
|
+
if (bare) units.push(bare);
|
|
165
|
+
for (const sub of subs) units.push(...splitUnits(sub, depth + 1));
|
|
166
|
+
}
|
|
167
|
+
return units.length ? units : [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Collapse shell quoting/escapes to the path value the shell would open.
|
|
172
|
+
* - unquoted \x → x; drop unescaped ' and " anywhere (not just edges)
|
|
173
|
+
* - single-quoted text is literal (including $ and \) — closes C3 while
|
|
174
|
+
* keeping `'$HOME'/.env` as the literal path $HOME/.env
|
|
175
|
+
* - double-quoted: \ only escapes $ ` " \ newline; $ residue stays so
|
|
176
|
+
* isUnresolvedPath still fires for "$HOME"/.env
|
|
177
|
+
* Unclosed quote → return s unchanged (fail closed: ambiguous).
|
|
178
|
+
*/
|
|
179
|
+
function collapseShellToken(s: string): string {
|
|
180
|
+
let out = "";
|
|
181
|
+
let i = 0;
|
|
182
|
+
while (i < s.length) {
|
|
183
|
+
const c = s[i]!;
|
|
184
|
+
if (c === "'") {
|
|
185
|
+
i++;
|
|
186
|
+
let closed = false;
|
|
187
|
+
while (i < s.length) {
|
|
188
|
+
if (s[i] === "'") {
|
|
189
|
+
closed = true;
|
|
190
|
+
i++;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
out += s[i++];
|
|
194
|
+
}
|
|
195
|
+
if (!closed) return s;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (c === '"') {
|
|
199
|
+
i++;
|
|
200
|
+
let closed = false;
|
|
201
|
+
while (i < s.length) {
|
|
202
|
+
if (s[i] === '"') {
|
|
203
|
+
closed = true;
|
|
204
|
+
i++;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
if (s[i] === "\\" && i + 1 < s.length) {
|
|
208
|
+
const n = s[i + 1]!;
|
|
209
|
+
// bash: inside double quotes only $ ` " \ and newline are special
|
|
210
|
+
if (n === "$" || n === "`" || n === '"' || n === "\\" || n === "\n") {
|
|
211
|
+
out += n;
|
|
212
|
+
i += 2;
|
|
213
|
+
} else {
|
|
214
|
+
out += s[i++]; // keep the backslash
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
out += s[i++];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (!closed) return s;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (c === "\\" && i + 1 < s.length) {
|
|
224
|
+
out += s[i + 1];
|
|
225
|
+
i += 2;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
out += c;
|
|
229
|
+
i++;
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function unquote(s: string): string {
|
|
235
|
+
return collapseShellToken(s).trim();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Normalize a raw argv token into a path candidate:
|
|
240
|
+
* - strip @file upload prefix, trailing ) from subs
|
|
241
|
+
* - strip trailing shell operators glued on (`cat .env;true` → `.env`)
|
|
242
|
+
* - peel git pathspecs (`HEAD:.env` → `.env`)
|
|
243
|
+
* - peel dd-style if=/of= assignments
|
|
244
|
+
* - collapse intra-word quotes/escapes (`.en''v` → `.env`) so path globs match
|
|
245
|
+
*/
|
|
246
|
+
export function cleanPathToken(token: string): string {
|
|
247
|
+
let t = token.trim();
|
|
248
|
+
if (!t) return "";
|
|
249
|
+
t = t.replace(/^@/, "");
|
|
250
|
+
// Operators glued to the path without whitespace (bypass vector).
|
|
251
|
+
t = t.replace(/[;|&`].*$/, "");
|
|
252
|
+
t = t.replace(/\)+$/, "").trim();
|
|
253
|
+
if (!t) return "";
|
|
254
|
+
|
|
255
|
+
// dd/install style: if=.env of=/tmp/x
|
|
256
|
+
const assign = t.match(
|
|
257
|
+
/^(?:if|of|in|out|file|path|filename|dest|source)=(.+)$/i,
|
|
258
|
+
);
|
|
259
|
+
if (assign?.[1]) t = assign[1];
|
|
260
|
+
|
|
261
|
+
// git pathspec rev:path / :path — not Windows drive (C:\… / C:/…)
|
|
262
|
+
if (
|
|
263
|
+
t.includes(":") &&
|
|
264
|
+
!/^[A-Za-z]:[\\/]/.test(t) &&
|
|
265
|
+
!/^[A-Za-z]:$/.test(t)
|
|
266
|
+
) {
|
|
267
|
+
const pathPart = t.slice(t.lastIndexOf(":") + 1);
|
|
268
|
+
if (pathPart) t = pathPart;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// After operator/assign/pathspec peel: collapse quotes so **/.env matches.
|
|
272
|
+
return collapseShellToken(t).trim();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* True when the token still needs shell expansion before path policy can allow.
|
|
277
|
+
* Quote-aware: single-quoted text is literal (`'$HOME'` is not an expansion);
|
|
278
|
+
* `$VAR` / `${…}` / `$(…)` / `$ '…'` / globs / braces outside single quotes
|
|
279
|
+
* (and `$` inside double quotes) still force unresolved.
|
|
280
|
+
*/
|
|
281
|
+
export function isUnresolvedPath(token: string): boolean {
|
|
282
|
+
if (!token) return false;
|
|
283
|
+
let i = 0;
|
|
284
|
+
while (i < token.length) {
|
|
285
|
+
const c = token[i]!;
|
|
286
|
+
if (c === "'") {
|
|
287
|
+
const end = token.indexOf("'", i + 1);
|
|
288
|
+
if (end < 0) return true; // unclosed — fail closed
|
|
289
|
+
i = end + 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (c === '"') {
|
|
293
|
+
i++;
|
|
294
|
+
while (i < token.length && token[i] !== '"') {
|
|
295
|
+
if (token[i] === "\\" && i + 1 < token.length) {
|
|
296
|
+
i += 2;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// $ expands inside double quotes; globs do not
|
|
300
|
+
if (token[i] === "$" && i + 1 < token.length) {
|
|
301
|
+
const n = token[i + 1]!;
|
|
302
|
+
if (n === "'" || n === "{" || n === "(" || /[A-Za-z_]/.test(n)) return true;
|
|
303
|
+
}
|
|
304
|
+
i++;
|
|
305
|
+
}
|
|
306
|
+
if (i >= token.length) return true; // unclosed
|
|
307
|
+
i++;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (c === "\\" && i + 1 < token.length) {
|
|
311
|
+
i += 2;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
// unquoted glob / brace / $
|
|
315
|
+
if (c === "*" || c === "?" || c === "[" || c === "{") return true;
|
|
316
|
+
if (c === "$" && i + 1 < token.length) {
|
|
317
|
+
const n = token[i + 1]!;
|
|
318
|
+
if (n === "'" || n === "{" || n === "(" || /[A-Za-z_]/.test(n)) return true;
|
|
319
|
+
}
|
|
320
|
+
i++;
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Looks like a path argument (has a slash or a dotfile/ext), not a flag.
|
|
326
|
+
function looksLikePath(token: string): boolean {
|
|
327
|
+
if (!token || token.startsWith("-")) return false;
|
|
328
|
+
if (token.includes("=")) return false; // env assignment / --opt=val left after clean
|
|
329
|
+
return (
|
|
330
|
+
token.includes("/") ||
|
|
331
|
+
/^\.?[\w.-]+\.\w+$/.test(token) ||
|
|
332
|
+
token.startsWith(".") ||
|
|
333
|
+
// git pathspec residue or plain secret basenames without a dot (id_rsa)
|
|
334
|
+
/^(id_rsa|id_ed25519|id_ecdsa|id_dsa|shadow|gshadow|sudoers|kubeconfig)$/i.test(
|
|
335
|
+
token,
|
|
336
|
+
)
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Path-like arguments across the whole command line, including redirect targets
|
|
342
|
+
* (`> file`, `>> file`, `2> file`). Quotes stripped; returned cleaned.
|
|
343
|
+
*/
|
|
344
|
+
export function pathArgs(command: string): string[] {
|
|
345
|
+
const src = stripComments(stripHeredocs(command));
|
|
346
|
+
const out: string[] = [];
|
|
347
|
+
|
|
348
|
+
const push = (raw: string) => {
|
|
349
|
+
const t = cleanPathToken(raw);
|
|
350
|
+
if (t && (looksLikePath(t) || isUnresolvedPath(t))) out.push(t);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// redirect targets: >, >>, <, 2>, &> followed by a filename (but NOT <( > ( process subs)
|
|
354
|
+
const redir = /(?:\d*&?>{1,2}|<)\s*("[^"]+"|'[^']+'|(?!\()\S+)/g;
|
|
355
|
+
let m: RegExpExecArray | null;
|
|
356
|
+
while ((m = redir.exec(src))) {
|
|
357
|
+
const t = unquote(m[1]!);
|
|
358
|
+
if (t && !t.startsWith("&")) push(t);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// bare tokens (and if=/.git pathspec forms via cleanPathToken)
|
|
362
|
+
for (const rawTok of src.split(/\s+/)) {
|
|
363
|
+
if (!rawTok) continue;
|
|
364
|
+
// Keep if=.env visible to cleanPathToken (looksLikePath alone would skip `=`).
|
|
365
|
+
if (/^(?:if|of|in|out|file|path|filename|dest|source)=/i.test(rawTok)) {
|
|
366
|
+
push(rawTok);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
push(rawTok.replace(/^[<>]+\(?/, ""));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return [...new Set(out)];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* True when the line has unquoted glob/brace/ANSI-C tokens so path policy
|
|
377
|
+
* cannot prove the real path — caller should force ask (never silent allow).
|
|
378
|
+
*/
|
|
379
|
+
export function hasUnresolvedExpansion(command: string): boolean {
|
|
380
|
+
const src = stripComments(stripHeredocs(command));
|
|
381
|
+
for (const rawTok of src.split(/\s+/)) {
|
|
382
|
+
if (!rawTok || rawTok.startsWith("-")) continue;
|
|
383
|
+
// Quote-aware on the raw token first: '$HOME' is literal, "$HOME" is not.
|
|
384
|
+
// Do not re-scan the collapsed form for $ — that would turn single-quoted
|
|
385
|
+
// literal $ into a false expansion after cleanPathToken strips the quotes.
|
|
386
|
+
if (isUnresolvedPath(rawTok)) return true;
|
|
387
|
+
const t = cleanPathToken(rawTok);
|
|
388
|
+
// Globs/braces that survive collapse (unquoted) still force ask.
|
|
389
|
+
if (t && /[*?[{]/.test(t)) return true;
|
|
390
|
+
}
|
|
391
|
+
// Whole-line $'…' even when glued
|
|
392
|
+
if (/\$'[^']*'/.test(src)) return true;
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a human-readable permission prompt from a tool call.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { baseCommand } from "./base-command.ts";
|
|
7
|
+
import { normalizePath } from "./wildcard.ts";
|
|
8
|
+
|
|
9
|
+
export type PermissionPrompt = {
|
|
10
|
+
/** Short subject shown in header + used as permanent-allow key. */
|
|
11
|
+
base: string;
|
|
12
|
+
/** Full description of what will run (command line / tool action). */
|
|
13
|
+
display: string;
|
|
14
|
+
/** Optional extra context under the display. */
|
|
15
|
+
detail?: string;
|
|
16
|
+
/** Human blast-radius label for the session-allow option. */
|
|
17
|
+
session?: string;
|
|
18
|
+
/** Human blast-radius label for the permanent-allow option. */
|
|
19
|
+
permanent?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ToolCallDescription = {
|
|
23
|
+
toolName: string;
|
|
24
|
+
/** bash command string, or tool name for tools */
|
|
25
|
+
subject: string;
|
|
26
|
+
/** Absolute-ish path for write/edit/read when present */
|
|
27
|
+
filePath?: string;
|
|
28
|
+
prompt: PermissionPrompt;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function summarize(text: string, max = 200): string {
|
|
32
|
+
const one = text.replace(/\s+/g, " ").trim();
|
|
33
|
+
if (one.length <= max) return one;
|
|
34
|
+
return `${one.slice(0, max - 1)}…`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolvePath(p: string, cwd?: string): string {
|
|
38
|
+
const raw = p.trim();
|
|
39
|
+
if (!raw) return raw;
|
|
40
|
+
try {
|
|
41
|
+
return normalizePath(cwd ? resolve(cwd, raw) : resolve(raw));
|
|
42
|
+
} catch {
|
|
43
|
+
return normalizePath(raw);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function describeToolCall(
|
|
48
|
+
toolName: string,
|
|
49
|
+
input: Record<string, unknown>,
|
|
50
|
+
cwd?: string,
|
|
51
|
+
): ToolCallDescription {
|
|
52
|
+
if (toolName === "bash" || toolName === "sudo_run") {
|
|
53
|
+
const command = String(input.command ?? "");
|
|
54
|
+
const base = baseCommand(command) || "(unknown)";
|
|
55
|
+
const reason =
|
|
56
|
+
toolName === "sudo_run" && typeof input.reason === "string"
|
|
57
|
+
? input.reason.trim()
|
|
58
|
+
: "";
|
|
59
|
+
const details: string[] = [];
|
|
60
|
+
if (toolName === "sudo_run") details.push("via sudo_run (root)");
|
|
61
|
+
if (reason) details.push(reason);
|
|
62
|
+
if (input.cwd) details.push(`cwd: ${String(input.cwd)}`);
|
|
63
|
+
return {
|
|
64
|
+
toolName,
|
|
65
|
+
subject: command,
|
|
66
|
+
prompt: {
|
|
67
|
+
base,
|
|
68
|
+
display: toolName === "sudo_run" ? `sudo ${command}` : command,
|
|
69
|
+
detail: details.length ? details.join(" · ") : undefined,
|
|
70
|
+
session: "Allow for this session",
|
|
71
|
+
permanent: "Allow permanently",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (toolName === "write") {
|
|
77
|
+
const rawPath = String(input.path ?? "(no path)");
|
|
78
|
+
const filePath = rawPath === "(no path)" ? undefined : resolvePath(rawPath, cwd);
|
|
79
|
+
const content = typeof input.content === "string" ? input.content : "";
|
|
80
|
+
const lines = content ? content.split("\n").length : 0;
|
|
81
|
+
return {
|
|
82
|
+
toolName: "write",
|
|
83
|
+
subject: "write",
|
|
84
|
+
filePath,
|
|
85
|
+
prompt: {
|
|
86
|
+
base: filePath ?? "write",
|
|
87
|
+
display: `write ${filePath ?? rawPath}`,
|
|
88
|
+
session: "Allow writes to this file this session",
|
|
89
|
+
permanent: "Allow writes to this file permanently",
|
|
90
|
+
detail: content
|
|
91
|
+
? `${lines} line(s), ${content.length} chars · preview: ${summarize(content, 160)}`
|
|
92
|
+
: "empty content",
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (toolName === "edit") {
|
|
98
|
+
const rawPath = String(input.path ?? "(no path)");
|
|
99
|
+
const filePath = rawPath === "(no path)" ? undefined : resolvePath(rawPath, cwd);
|
|
100
|
+
const edits = Array.isArray(input.edits) ? input.edits : [];
|
|
101
|
+
const first = edits[0] as { oldText?: string; newText?: string } | undefined;
|
|
102
|
+
const detailParts = [`${edits.length} edit(s)`];
|
|
103
|
+
if (first?.oldText) detailParts.push(`old: ${summarize(first.oldText, 80)}`);
|
|
104
|
+
if (first?.newText) detailParts.push(`new: ${summarize(first.newText, 80)}`);
|
|
105
|
+
return {
|
|
106
|
+
toolName: "edit",
|
|
107
|
+
subject: "edit",
|
|
108
|
+
filePath,
|
|
109
|
+
prompt: {
|
|
110
|
+
base: filePath ?? "edit",
|
|
111
|
+
display: `edit ${filePath ?? rawPath}`,
|
|
112
|
+
session: "Allow edits to this file this session",
|
|
113
|
+
permanent: "Allow edits to this file permanently",
|
|
114
|
+
detail: detailParts.join(" · "),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (toolName === "read") {
|
|
120
|
+
const rawPath = String(input.path ?? "(no path)");
|
|
121
|
+
const filePath = rawPath === "(no path)" ? undefined : resolvePath(rawPath, cwd);
|
|
122
|
+
return {
|
|
123
|
+
toolName: "read",
|
|
124
|
+
subject: "read",
|
|
125
|
+
filePath,
|
|
126
|
+
prompt: {
|
|
127
|
+
base: filePath ?? "read",
|
|
128
|
+
display: `read ${filePath ?? rawPath}`,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
toolName,
|
|
135
|
+
subject: toolName,
|
|
136
|
+
prompt: {
|
|
137
|
+
base: toolName,
|
|
138
|
+
display: `${toolName} ${summarize(JSON.stringify(input), 240)}`,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for pi-ask-user: text sanitization, width-safe display
|
|
3
|
+
* primitives, question introspection, and per-tab state construction.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { EditorTheme } from "@earendil-works/pi-tui";
|
|
8
|
+
import { Editor, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import {
|
|
10
|
+
type Answer,
|
|
11
|
+
type AskUserResult,
|
|
12
|
+
ICON_OTHER,
|
|
13
|
+
type Question,
|
|
14
|
+
type RenderOption,
|
|
15
|
+
type TabState,
|
|
16
|
+
type TuiLike,
|
|
17
|
+
} from "./types.ts";
|
|
18
|
+
|
|
19
|
+
export function wrapTab(index: number, total: number): number {
|
|
20
|
+
if (total <= 0) return 0;
|
|
21
|
+
return ((index % total) + total) % total;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalize externally-supplied text for TUI rendering. Fold CR/CRLF into real
|
|
26
|
+
* newlines, convert tabs to a single space, and strip remaining C0 control
|
|
27
|
+
* chars (keeping only \n). Rationale: a raw \r returns the cursor to column 0
|
|
28
|
+
* mid-row and clobbers leading indent; a raw \t advances to the next terminal
|
|
29
|
+
* tab stop (which the panel's width math counts as 1 col, so rows overflow
|
|
30
|
+
* their declared width and redraws accumulate stale copies); other C0 bytes
|
|
31
|
+
* corrupt layout too. Callers then treat \n as the only meaningful break.
|
|
32
|
+
*/
|
|
33
|
+
export function sanitizeMultiline(text: string): string {
|
|
34
|
+
// Build the control-char class from codepoints so no literal control bytes
|
|
35
|
+
// appear in source (keeps biome's noControlCharactersInRegex happy). \x09
|
|
36
|
+
// (tab) is handled separately below; \x0a (\n) is the one char we keep.
|
|
37
|
+
const controls = new RegExp("[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]", "g");
|
|
38
|
+
return text
|
|
39
|
+
.replace(/\r\n?/g, "\n")
|
|
40
|
+
.replace(/\t/g, " ")
|
|
41
|
+
.replace(controls, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sanitizeTabDisplay(tab: string): string {
|
|
45
|
+
return sanitizeMultiline(tab).replace(/\n/g, " ").trim() || "(unnamed)";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build the full option list for a question, always appending the "Type something." custom-input row. */
|
|
49
|
+
export function buildOptions(q: Question): RenderOption[] {
|
|
50
|
+
const opts: RenderOption[] = [...q.options];
|
|
51
|
+
opts.push({ label: "Type something.", isOther: true });
|
|
52
|
+
return opts;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isMulti(q: Question | undefined): boolean {
|
|
56
|
+
return !!q?.multiSelect;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether the user is allowed to skip this question (default true). */
|
|
60
|
+
export function canSkip(q: Question | undefined): boolean {
|
|
61
|
+
return q?.allowSkip !== false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Does this question use the two-column (options | preview) layout? */
|
|
65
|
+
export function isDualColumn(q: Question | undefined): boolean {
|
|
66
|
+
if (!q) return false;
|
|
67
|
+
return q.options.some((o) => o.preview);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function newTabState(
|
|
71
|
+
tui: TuiLike,
|
|
72
|
+
theme: EditorTheme,
|
|
73
|
+
tabIndex: number,
|
|
74
|
+
onSubmit: (tabIndex: number, value: string) => void,
|
|
75
|
+
): TabState {
|
|
76
|
+
const editor = new Editor(tui as never, theme);
|
|
77
|
+
editor.onSubmit = (value) => onSubmit(tabIndex, value);
|
|
78
|
+
return {
|
|
79
|
+
cursor: 0,
|
|
80
|
+
scrollOffset: 0,
|
|
81
|
+
inputMode: false,
|
|
82
|
+
editor,
|
|
83
|
+
multiChecked: new Set(),
|
|
84
|
+
customText: null,
|
|
85
|
+
selectedSingle: -1,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function errorResult(
|
|
90
|
+
message: string,
|
|
91
|
+
questions: Question[] = [],
|
|
92
|
+
): {
|
|
93
|
+
content: { type: "text"; text: string }[];
|
|
94
|
+
details: AskUserResult;
|
|
95
|
+
} {
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: "text", text: message }],
|
|
98
|
+
details: { questions, answers: [], cancelled: true },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Pad a string with trailing spaces to a visible width (left-justified). */
|
|
103
|
+
export function padRight(s: string, width: number): string {
|
|
104
|
+
const v = visibleWidth(s);
|
|
105
|
+
return v >= width ? s : s + " ".repeat(width - v);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Truncate to a visible width, appending “…” only when the text actually
|
|
109
|
+
* overflows. (truncateToWidth's third arg is a fill, not a suffix, so we
|
|
110
|
+
* reserve one column and append the ellipsis ourselves when needed.) */
|
|
111
|
+
export function truncForDisplay(text: string, maxW: number): string {
|
|
112
|
+
if (maxW <= 0) return "";
|
|
113
|
+
if (maxW === 1) return "…";
|
|
114
|
+
const vw = visibleWidth(text);
|
|
115
|
+
if (vw <= maxW) return text;
|
|
116
|
+
return truncateToWidth(text, maxW - 1, "") + "…";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Structured interpretation of an Answer for display/serialization.
|
|
120
|
+
* Single source of truth — all three consumers (review screen's
|
|
121
|
+
* formatAnswerText, result card's formatAnswer, execute's JSON payload)
|
|
122
|
+
* derive from this, so they can never drift apart. */
|
|
123
|
+
export interface AnswerView {
|
|
124
|
+
/** Human-readable text WITHOUT ANSI — e.g. "Sidebar" / "甲, 乙" /
|
|
125
|
+
* "✎ 自定义文本" / "(none)" / "(skipped)". Consumers wrap it in color. */
|
|
126
|
+
text: string;
|
|
127
|
+
/** Theme color name for the whole text. */
|
|
128
|
+
color: ThemeColor;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Interpret an Answer into display form. `customGlyph` (default "✎") prefixes
|
|
132
|
+
* any custom text. Returns `(no answer)` / dim for an absent answer. */
|
|
133
|
+
export function describeAnswer(ans: Answer | undefined, customGlyph = ICON_OTHER): AnswerView {
|
|
134
|
+
if (!ans) return { text: "(no answer)", color: "dim" };
|
|
135
|
+
switch (ans.kind) {
|
|
136
|
+
case "skipped":
|
|
137
|
+
return { text: "(skipped)", color: "warning" };
|
|
138
|
+
case "multi": {
|
|
139
|
+
if (ans.options.length === 0 && !ans.custom) return { text: "(none)", color: "dim" };
|
|
140
|
+
const parts = [...ans.options];
|
|
141
|
+
if (ans.custom) parts.push(`${customGlyph} ${ans.custom}`);
|
|
142
|
+
return { text: parts.join(", "), color: "text" };
|
|
143
|
+
}
|
|
144
|
+
case "custom":
|
|
145
|
+
return { text: `${customGlyph} ${ans.text}`, color: "text" };
|
|
146
|
+
case "single":
|
|
147
|
+
return { text: ans.option, color: "text" };
|
|
148
|
+
}
|
|
149
|
+
}
|