@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
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// ask_permission + automatic gate for bash / write / edit / sudo_run / sensitive reads.
|
|
2
|
+
// Path wildcards: most-specific wins; equal score → deny.
|
|
3
|
+
// Prompt: Allow this | Allow for this session | Allow permanently | Deny with reason
|
|
4
|
+
// Bash: every unit is checked; one prompt per tool call (not per unit).
|
|
5
|
+
// Session remembers all ask-bases; permanent writes the primary base only.
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import {
|
|
10
|
+
baseCommand,
|
|
11
|
+
isPersistableBashKey,
|
|
12
|
+
isSudoPrefixed,
|
|
13
|
+
stripSudoPrefix,
|
|
14
|
+
} from "./base-command.ts";
|
|
15
|
+
import { splitUnits } from "./bash-scan.ts";
|
|
16
|
+
import { describeToolCall } from "./describe-tool.ts";
|
|
17
|
+
import {
|
|
18
|
+
type PermissionDecision,
|
|
19
|
+
type PermissionPrompt,
|
|
20
|
+
runPermissionPanel,
|
|
21
|
+
} from "./permission-panel.ts";
|
|
22
|
+
import { PermissionStore } from "./permission-store.ts";
|
|
23
|
+
|
|
24
|
+
export type { PermissionDecision };
|
|
25
|
+
export { describeToolCall };
|
|
26
|
+
|
|
27
|
+
/** Tools that prompt unless allow/deny rule hits. */
|
|
28
|
+
const ASK_TOOLS = new Set(["bash", "write", "edit"]);
|
|
29
|
+
/** Only auto-block on path deny — never prompt on normal reads. */
|
|
30
|
+
const PATH_DENY_TOOLS = new Set(["read", "write", "edit"]);
|
|
31
|
+
|
|
32
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
33
|
+
function enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
34
|
+
const run = queue.then(fn, fn);
|
|
35
|
+
queue = run.then(
|
|
36
|
+
() => undefined,
|
|
37
|
+
() => undefined,
|
|
38
|
+
);
|
|
39
|
+
return run;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function blockReason(
|
|
43
|
+
kind: "path" | "bash" | "tool" | "sudo_redirect",
|
|
44
|
+
label: string,
|
|
45
|
+
extra?: string,
|
|
46
|
+
): string {
|
|
47
|
+
if (kind === "sudo_redirect") {
|
|
48
|
+
return (
|
|
49
|
+
`Privileged commands must use the sudo_run tool, not bash. ` +
|
|
50
|
+
`Call sudo_run with command: ${label}` +
|
|
51
|
+
(extra ? ` (${extra})` : "")
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (kind === "path") {
|
|
55
|
+
return (
|
|
56
|
+
`Blocked by path rule for \`${label}\`. ` +
|
|
57
|
+
`Add a paths allow entry in permission.json to override.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return (
|
|
61
|
+
`Blocked by permission rule for \`${label}\`. ` +
|
|
62
|
+
`Add an allow entry in permission.json to override.` +
|
|
63
|
+
(extra ? ` ${extra}` : "")
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function promptPermission(
|
|
68
|
+
ctx: Pick<ExtensionContext, "ui" | "hasUI">,
|
|
69
|
+
store: PermissionStore,
|
|
70
|
+
toolName: string,
|
|
71
|
+
subject: string,
|
|
72
|
+
prompt: PermissionPrompt,
|
|
73
|
+
filePath?: string,
|
|
74
|
+
): Promise<PermissionDecision> {
|
|
75
|
+
if (!ctx.hasUI) {
|
|
76
|
+
return {
|
|
77
|
+
action: "deny",
|
|
78
|
+
reason: "Action requires approval, but no interactive UI is available.",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return enqueue(async () => {
|
|
83
|
+
if (store.isAllowed(toolName, subject, filePath)) return { action: "allow_once" };
|
|
84
|
+
if (store.isDenied(toolName, subject, filePath)) {
|
|
85
|
+
return {
|
|
86
|
+
action: "deny",
|
|
87
|
+
reason: blockReason(filePath ? "path" : "bash", prompt.base),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const decision = await runPermissionPanel(ctx, prompt);
|
|
92
|
+
|
|
93
|
+
if (decision.action === "allow_always") {
|
|
94
|
+
const entry = store.allowPermanently(toolName, subject, filePath);
|
|
95
|
+
return { action: "allow_always", entry };
|
|
96
|
+
}
|
|
97
|
+
if (decision.action === "allow_session") {
|
|
98
|
+
store.allowSession(toolName, subject, filePath);
|
|
99
|
+
return { action: "allow_session" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return decision;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type BashGateResult =
|
|
107
|
+
| { ok: true; decision: "already_allowed" | "approved"; entries: string[] }
|
|
108
|
+
| { ok: false; reason: string; decision: "deny" | "cancelled" };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Gate a bash line: every unit + path arg is checked, but the user is prompted
|
|
112
|
+
* at most once for the whole tool call. Session allow covers every ask-base;
|
|
113
|
+
* permanent allow writes only the primary base.
|
|
114
|
+
*/
|
|
115
|
+
export async function gateBashCommand(
|
|
116
|
+
ctx: Pick<ExtensionContext, "ui" | "hasUI">,
|
|
117
|
+
store: PermissionStore,
|
|
118
|
+
command: string,
|
|
119
|
+
opts?: { cwd?: string; detail?: string },
|
|
120
|
+
): Promise<BashGateResult> {
|
|
121
|
+
const plan = store.planBash(command, { cwd: opts?.cwd });
|
|
122
|
+
if (plan.action === "deny") {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
decision: "deny",
|
|
126
|
+
reason: blockReason(plan.kind, plan.label),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (plan.action === "allow") {
|
|
130
|
+
return { ok: true, decision: "already_allowed", entries: [] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Session remembers every real base in the chain; permanent only the primary.
|
|
134
|
+
const bases = store.askBases(plan.units);
|
|
135
|
+
const cmdBase = baseCommand(command);
|
|
136
|
+
const primary =
|
|
137
|
+
bases[0] || (isPersistableBashKey(cmdBase) ? cmdBase : "") || "bash";
|
|
138
|
+
|
|
139
|
+
const prompt: PermissionPrompt = {
|
|
140
|
+
base: primary,
|
|
141
|
+
display: command,
|
|
142
|
+
detail: opts?.detail?.trim() || undefined,
|
|
143
|
+
// Fixed short labels (A/B/C) — bases stay out of the option text.
|
|
144
|
+
session: "Allow for this session",
|
|
145
|
+
permanent: "Allow permanently",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (!ctx.hasUI) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
decision: "deny",
|
|
152
|
+
reason: "Action requires approval, but no interactive UI is available.",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result = await enqueue(async () => {
|
|
157
|
+
// Re-check after queue wait — a prior session allow may already cover us.
|
|
158
|
+
const again = store.planBash(command, { cwd: opts?.cwd });
|
|
159
|
+
if (again.action === "allow") return { action: "allow_once" as const };
|
|
160
|
+
if (again.action === "deny") {
|
|
161
|
+
return {
|
|
162
|
+
action: "deny" as const,
|
|
163
|
+
reason: blockReason(again.kind, again.label),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return runPermissionPanel(ctx, prompt);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
switch (result.action) {
|
|
170
|
+
case "allow_once":
|
|
171
|
+
return { ok: true, decision: "approved", entries: [] };
|
|
172
|
+
case "allow_session": {
|
|
173
|
+
// Whole chain: every base that still needed approval this call.
|
|
174
|
+
const entries = store.allowSessionBases(bases.length ? bases : [primary]);
|
|
175
|
+
return { ok: true, decision: "approved", entries };
|
|
176
|
+
}
|
|
177
|
+
case "allow_always": {
|
|
178
|
+
// C: permanent = primary base only (not every unit in the pipeline).
|
|
179
|
+
const entries = store.allowPermanentlyBases([primary]);
|
|
180
|
+
return { ok: true, decision: "approved", entries };
|
|
181
|
+
}
|
|
182
|
+
case "deny":
|
|
183
|
+
return { ok: false, decision: "deny", reason: result.reason };
|
|
184
|
+
case "cancelled":
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
decision: "cancelled",
|
|
188
|
+
reason: "Permission prompt cancelled by user.",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const AskPermissionParams = Type.Object({
|
|
194
|
+
command: Type.String({
|
|
195
|
+
description: "The system command (or action description) that needs approval",
|
|
196
|
+
}),
|
|
197
|
+
detail: Type.Optional(
|
|
198
|
+
Type.String({
|
|
199
|
+
description: "Optional extra context shown under the command (why it's needed)",
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
export function registerPermission(pi: ExtensionAPI, store: PermissionStore): void {
|
|
205
|
+
store.ensureUserFile();
|
|
206
|
+
|
|
207
|
+
pi.registerTool({
|
|
208
|
+
name: "ask_permission",
|
|
209
|
+
label: "Ask Permission",
|
|
210
|
+
description:
|
|
211
|
+
"Request user approval before a sensitive system action. Prefer letting the automatic gate handle bash/write/edit/sudo_run — call this only for other sensitive actions. One prompt per call (chains checked as units, prompted once).",
|
|
212
|
+
parameters: AskPermissionParams,
|
|
213
|
+
executionMode: "sequential",
|
|
214
|
+
|
|
215
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
216
|
+
const command = params.command;
|
|
217
|
+
const units = splitUnits(command);
|
|
218
|
+
const bases = units.map((u) => baseCommand(u) || u);
|
|
219
|
+
|
|
220
|
+
const result = await gateBashCommand(ctx, store, command, {
|
|
221
|
+
cwd: ctx.cwd,
|
|
222
|
+
detail: params.detail,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const payload = result.ok
|
|
226
|
+
? {
|
|
227
|
+
approved: true as const,
|
|
228
|
+
decision: result.decision,
|
|
229
|
+
command,
|
|
230
|
+
bases,
|
|
231
|
+
entries: result.entries.length ? result.entries : undefined,
|
|
232
|
+
}
|
|
233
|
+
: {
|
|
234
|
+
approved: false as const,
|
|
235
|
+
decision: result.decision,
|
|
236
|
+
command,
|
|
237
|
+
bases,
|
|
238
|
+
reason: result.reason,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
243
|
+
details: payload,
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
249
|
+
const toolName = event.toolName;
|
|
250
|
+
const input = (event.input ?? {}) as Record<string, unknown>;
|
|
251
|
+
|
|
252
|
+
// ── bash: one prompt per call; sudo/doas (any unit) → sudo_run redirect ──
|
|
253
|
+
if (toolName === "bash") {
|
|
254
|
+
const command = String(input.command ?? "");
|
|
255
|
+
if (!command.trim()) return undefined;
|
|
256
|
+
|
|
257
|
+
// Leading sudo still short-circuits here; mid-chain is caught in planBash.
|
|
258
|
+
if (isSudoPrefixed(command)) {
|
|
259
|
+
const inner = stripSudoPrefix(command) || command;
|
|
260
|
+
return {
|
|
261
|
+
block: true,
|
|
262
|
+
reason: blockReason("sudo_redirect", JSON.stringify(inner)),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const result = await gateBashCommand(ctx, store, command, { cwd: ctx.cwd });
|
|
267
|
+
if (result.ok) return undefined;
|
|
268
|
+
return { block: true, reason: result.reason };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── sudo_run: not gated here. pix-sudo's PAM password prompt IS the gate
|
|
272
|
+
// (no password entered = denied). JSON gating would be redundant. ──
|
|
273
|
+
|
|
274
|
+
const isAskTool = ASK_TOOLS.has(toolName);
|
|
275
|
+
const isPathDenyTool = PATH_DENY_TOOLS.has(toolName);
|
|
276
|
+
if (!isAskTool && !isPathDenyTool) return undefined;
|
|
277
|
+
|
|
278
|
+
const desc = describeToolCall(toolName, input, ctx.cwd);
|
|
279
|
+
const decision = store.decide(toolName, desc.subject, desc.filePath);
|
|
280
|
+
|
|
281
|
+
if (decision.state === "allow") return undefined;
|
|
282
|
+
|
|
283
|
+
if (decision.state === "deny") {
|
|
284
|
+
return {
|
|
285
|
+
block: true,
|
|
286
|
+
reason: blockReason(
|
|
287
|
+
desc.filePath ? "path" : "tool",
|
|
288
|
+
desc.filePath ?? desc.prompt.base,
|
|
289
|
+
),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// state === "ask" — read only auto-denies; never prompt for normal reads
|
|
294
|
+
if (!isAskTool) return undefined;
|
|
295
|
+
|
|
296
|
+
const result = await promptPermission(
|
|
297
|
+
ctx,
|
|
298
|
+
store,
|
|
299
|
+
toolName,
|
|
300
|
+
desc.subject,
|
|
301
|
+
desc.prompt,
|
|
302
|
+
desc.filePath,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
switch (result.action) {
|
|
306
|
+
case "allow_once":
|
|
307
|
+
case "allow_session":
|
|
308
|
+
case "allow_always":
|
|
309
|
+
return undefined;
|
|
310
|
+
case "deny":
|
|
311
|
+
return { block: true, reason: result.reason };
|
|
312
|
+
case "cancelled":
|
|
313
|
+
return { block: true, reason: "Permission prompt cancelled by user." };
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
pi.on("session_start", () => {
|
|
318
|
+
store.clearSession();
|
|
319
|
+
store.reload();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
pi.registerCommand("permissions", {
|
|
323
|
+
description: "Show permission allows/denies (bash, tools, path wildcards)",
|
|
324
|
+
handler: async (_args, ctx) => {
|
|
325
|
+
store.reload();
|
|
326
|
+
const { bash, tools, paths, sessionBash, sessionTools, sessionPaths } = store.listAllowed();
|
|
327
|
+
const denied = store.listDeniedPaths();
|
|
328
|
+
const lines = [
|
|
329
|
+
...(store.yolo
|
|
330
|
+
? ["YOLO: on (auto-approving asks; denies + sudo_run redirect still enforced)"]
|
|
331
|
+
: []),
|
|
332
|
+
bash.length ? `bash allow: ${bash.join(", ")}` : "bash allow: (none extra)",
|
|
333
|
+
tools.length ? `tools allow: ${tools.join(", ")}` : "tools allow: (none)",
|
|
334
|
+
paths.length ? `paths allow:\n ${paths.join("\n ")}` : "paths allow: (none extra)",
|
|
335
|
+
denied.length ? `paths deny: ${denied.length} patterns` : "paths deny: (none)",
|
|
336
|
+
sessionBash.length || sessionTools.length || sessionPaths.length
|
|
337
|
+
? `session: bash[${sessionBash.join(", ") || "—"}] tools[${sessionTools.join(", ") || "—"}] paths[${sessionPaths.length}]`
|
|
338
|
+
: "session: (none)",
|
|
339
|
+
"",
|
|
340
|
+
"bash+sudo → redirected to sudo_run. sudo_run → gated by pix-sudo password prompt.",
|
|
341
|
+
`File: ${store.filePath}`,
|
|
342
|
+
];
|
|
343
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
pi.registerCommand("yolo", {
|
|
348
|
+
description:
|
|
349
|
+
"Toggle session YOLO (auto-approve asks; deny rules + sudo_run redirect still enforced)",
|
|
350
|
+
handler: async (args, ctx) => {
|
|
351
|
+
const a = (args ?? "").trim().toLowerCase();
|
|
352
|
+
if (a === "on") store.setYolo(true);
|
|
353
|
+
else if (a === "off") store.setYolo(false);
|
|
354
|
+
else store.setYolo(!store.yolo);
|
|
355
|
+
|
|
356
|
+
ctx.ui.notify(
|
|
357
|
+
store.yolo
|
|
358
|
+
? "YOLO on — auto-approving asks this session. Deny rules and sudo_run redirect are still enforced."
|
|
359
|
+
: "YOLO off — prompts restored.",
|
|
360
|
+
"info",
|
|
361
|
+
);
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeBox schema for the ask_user tool parameters.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
|
|
7
|
+
const QuestionOptionSchema = Type.Object({
|
|
8
|
+
label: Type.String({
|
|
9
|
+
description: "Short display label for the option (shown on the selection row)",
|
|
10
|
+
}),
|
|
11
|
+
description: Type.Optional(
|
|
12
|
+
Type.String({
|
|
13
|
+
description:
|
|
14
|
+
"Short explanation shown under the label (wraps). Add one when the label alone isn't self-explanatory.",
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
preview: Type.Optional(
|
|
18
|
+
Type.String({
|
|
19
|
+
description:
|
|
20
|
+
"Use this when `description` (a short one-liner) is not enough and the user genuinely benefits from seeing more detail in a side column — e.g. an ASCII layout demo, a code skeleton, a Pro/Cons breakdown, or the reasoning behind why this option is offered and what choosing it entails. Rendered verbatim in a side column (spaces/newlines preserved). Do NOT treat preview as extra text capacity. Every line competes for the user's attention against the option list; only add a preview when the content is worth reading, not just because there's room for more words. If a short `description` already conveys the option, leave preview empty. Most options need only `description`.",
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const QuestionSchema = Type.Object({
|
|
26
|
+
header: Type.String({
|
|
27
|
+
description: "Short question title shown in the panel header, e.g. 'Which layout?'",
|
|
28
|
+
}),
|
|
29
|
+
tab: Type.String({
|
|
30
|
+
description:
|
|
31
|
+
'Short keyword that identifies this question. Shown on the tab bar when there are multiple questions, and returned in the result as the answer\'s prefix. Write it in the user\'s language (e.g. "数据库" or "布局" in a Chinese conversation, "Database" or "Layout" in English), not as a programmatic identifier like "db_choice". Must be unique across questions in one call.',
|
|
32
|
+
}),
|
|
33
|
+
prompt: Type.Optional(
|
|
34
|
+
Type.String({ description: "Optional longer body text shown under the header" }),
|
|
35
|
+
),
|
|
36
|
+
options: Type.Array(QuestionOptionSchema, {
|
|
37
|
+
description:
|
|
38
|
+
"Available options. Pass 2-4; each needs a short `label` + a `description`, and a `preview` only when a description can't fully convey the option.",
|
|
39
|
+
}),
|
|
40
|
+
multiSelect: Type.Optional(
|
|
41
|
+
Type.Boolean({
|
|
42
|
+
description:
|
|
43
|
+
"If true, the user may check multiple options (space toggles, enter commits). Default false.",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
allowSkip: Type.Optional(
|
|
47
|
+
Type.Boolean({
|
|
48
|
+
description:
|
|
49
|
+
"If false, the user MUST answer before proceeding (Tab/Enter with no selection is blocked). Default true. Use false for required questions.",
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const AskUserParams = Type.Object({
|
|
55
|
+
questions: Type.Array(QuestionSchema, { description: "One or more questions to ask" }),
|
|
56
|
+
});
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types and icon constants for pi-ask-user.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { Editor } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// Icon constants — all in U+25A0–25FF Geometric Shapes for font consistency
|
|
10
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export const ICON_RADIO_EMPTY = "○"; // U+25CB white circle
|
|
13
|
+
export const ICON_RADIO_FILLED = "◉"; // U+25C9 fisheye
|
|
14
|
+
export const ICON_CHECK_EMPTY = "□"; // U+25A1
|
|
15
|
+
export const ICON_CHECK_FILLED = "▣"; // U+25A3
|
|
16
|
+
export const ICON_OTHER = "✎"; // pencil for "Type something."
|
|
17
|
+
export const ICON_CURSOR = "▸"; // current cursor position, independent of selection
|
|
18
|
+
export const ICON_NOTE = ICON_OTHER; // same ✎ pencil as custom answers — the note is also free-form user input
|
|
19
|
+
export const ICON_ANSWER = "›"; // lead glyph on option-pick answers in the result card
|
|
20
|
+
|
|
21
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Toggle key
|
|
23
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Collapse/expand toggle. Ctrl+\ (0x1c) is free in pi's built-in keybindings
|
|
27
|
+
* (unlike Ctrl+] which collides with tui.editor.jumpForward) and is not used
|
|
28
|
+
* as a prefix by tmux/zellij/screen/ssh.
|
|
29
|
+
*/
|
|
30
|
+
export const TOGGLE_KEY = Key.ctrl("\\");
|
|
31
|
+
export const TOGGLE_HINT = "Ctrl+\\";
|
|
32
|
+
|
|
33
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// Types
|
|
35
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export interface QuestionOption {
|
|
38
|
+
label: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
/** Rich preview shown in the right column when this option is focused. */
|
|
41
|
+
preview?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RenderOption extends QuestionOption {
|
|
45
|
+
isOther?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Question {
|
|
49
|
+
/** Internal per-call identity. Never exposed in the tool's JSON result. */
|
|
50
|
+
id: string;
|
|
51
|
+
/** Original caller-provided key. Kept byte-for-byte for JSON results. */
|
|
52
|
+
tab: string;
|
|
53
|
+
/** Sanitized, non-empty label used exclusively by the TUI. */
|
|
54
|
+
displayTab: string;
|
|
55
|
+
header: string;
|
|
56
|
+
prompt?: string;
|
|
57
|
+
options: QuestionOption[];
|
|
58
|
+
multiSelect?: boolean;
|
|
59
|
+
allowSkip?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A committed answer. Discriminated by `kind` so every state carries exactly
|
|
63
|
+
* the fields it needs — the compiler guarantees completeness, and the three
|
|
64
|
+
* display/serialization consumers all derive from `describeAnswer()` (single
|
|
65
|
+
* source of truth) so they can never drift apart.
|
|
66
|
+
*
|
|
67
|
+
* - `single`: the user picked one of the offered options.
|
|
68
|
+
* - `custom`: single-select, the user typed a custom answer ("Type something.").
|
|
69
|
+
* - `multi`: multi-select — `options` are the picked option labels; an
|
|
70
|
+
* optional `custom` carries any typed text alongside them. Pure-custom
|
|
71
|
+
* (no options checked) is `options: []` + `custom`; an empty commit
|
|
72
|
+
* (skippable, submitted with nothing) is `options: []` with no `custom`.
|
|
73
|
+
* - `skipped`: the user navigated past without answering (Tab/arrows).
|
|
74
|
+
*/
|
|
75
|
+
export type Answer =
|
|
76
|
+
| { id: string; tab: string; kind: "single"; option: string }
|
|
77
|
+
| { id: string; tab: string; kind: "custom"; text: string }
|
|
78
|
+
| { id: string; tab: string; kind: "multi"; options: string[]; custom?: string }
|
|
79
|
+
| { id: string; tab: string; kind: "skipped" };
|
|
80
|
+
|
|
81
|
+
export interface AskUserResult {
|
|
82
|
+
questions: Question[];
|
|
83
|
+
answers: Answer[];
|
|
84
|
+
cancelled: boolean;
|
|
85
|
+
/** Free-form note the user can attach on the review screen. Absent when empty. */
|
|
86
|
+
message?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface PanelCallbacks {
|
|
90
|
+
onResult: (result: AskUserResult) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Per-tab ephemeral UI state. Preserved across tab switches. */
|
|
94
|
+
export interface TabState {
|
|
95
|
+
/** Cursor position (where ▸ is). */
|
|
96
|
+
cursor: number;
|
|
97
|
+
/** Vertical scroll offset for the options viewport. */
|
|
98
|
+
scrollOffset: number;
|
|
99
|
+
/** Whether "Type something." input mode is active for this tab. */
|
|
100
|
+
inputMode: boolean;
|
|
101
|
+
/** This tab's own editor instance (its draft lives inside; no cross-tab sync needed). */
|
|
102
|
+
editor: Editor;
|
|
103
|
+
/** Indices of committed options (multi-select). */
|
|
104
|
+
multiChecked: Set<number>;
|
|
105
|
+
/** Committed custom text for multi-select mode (kept alongside multiChecked, never overwriting it). Null if none. */
|
|
106
|
+
customText: string | null;
|
|
107
|
+
/** Committed single-select index, or -1 if none yet. */
|
|
108
|
+
selectedSingle: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Minimal TUI surface the panel/editors depend on. */
|
|
112
|
+
export interface TuiLike {
|
|
113
|
+
requestRender(): void;
|
|
114
|
+
}
|
package/src/wildcard.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path / command glob matching.
|
|
3
|
+
*
|
|
4
|
+
* * → any chars except /
|
|
5
|
+
* ** → any chars including /
|
|
6
|
+
* ? → one char except /
|
|
7
|
+
*
|
|
8
|
+
* Patterns with no `/` also match against the basename
|
|
9
|
+
* (so `*.env` matches `/a/b/foo.env` and `.env`, but not `.env.example`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MAX_PATTERN = 500;
|
|
13
|
+
|
|
14
|
+
export function normalizePath(p: string): string {
|
|
15
|
+
return p.replaceAll("\\", "/").replace(/\/{2,}/g, "/");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Compile a glob to a RegExp. Invalid/oversized patterns never match. */
|
|
19
|
+
export function compileGlob(pattern: string): RegExp {
|
|
20
|
+
if (!pattern || pattern.length > MAX_PATTERN) return /$^/;
|
|
21
|
+
|
|
22
|
+
const p = normalizePath(pattern);
|
|
23
|
+
let out = "^";
|
|
24
|
+
for (let i = 0; i < p.length; ) {
|
|
25
|
+
if (p[i] === "*" && p[i + 1] === "*") {
|
|
26
|
+
if (p[i + 2] === "/") {
|
|
27
|
+
// **/ → zero or more directories
|
|
28
|
+
out += "(?:.*/)?";
|
|
29
|
+
i += 3;
|
|
30
|
+
} else {
|
|
31
|
+
out += ".*";
|
|
32
|
+
i += 2;
|
|
33
|
+
}
|
|
34
|
+
} else if (p[i] === "*") {
|
|
35
|
+
out += "[^/]*";
|
|
36
|
+
i += 1;
|
|
37
|
+
} else if (p[i] === "?") {
|
|
38
|
+
out += "[^/]";
|
|
39
|
+
i += 1;
|
|
40
|
+
} else {
|
|
41
|
+
const c = p[i]!;
|
|
42
|
+
out += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
out += "$";
|
|
47
|
+
// nosemgrep: pattern is length-bounded + escaped; only * ? ** are wild
|
|
48
|
+
return new RegExp(out);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function matchGlob(pattern: string, value: string): boolean {
|
|
52
|
+
const re = compileGlob(pattern);
|
|
53
|
+
const norm = normalizePath(value);
|
|
54
|
+
if (re.test(norm)) return true;
|
|
55
|
+
|
|
56
|
+
// File-name-only patterns also match basename
|
|
57
|
+
if (!pattern.includes("/")) {
|
|
58
|
+
const base = norm.split("/").pop() ?? norm;
|
|
59
|
+
if (re.test(base)) return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Specificity score for a glob: more literal (non-wildcard) chars = more
|
|
66
|
+
* specific; fewer `*` breaks ties. So a deep dotfile deny beats a broad
|
|
67
|
+
* `Projects` tree allow for the same file even though both match.
|
|
68
|
+
*/
|
|
69
|
+
export function globSpecificity(pattern: string): number {
|
|
70
|
+
const p = normalizePath(pattern);
|
|
71
|
+
// Literal chars AFTER the last wildcard pin the filename/tail — the strongest
|
|
72
|
+
// specificity signal. A pattern ending in `**` (broad tree) pins nothing → 0,
|
|
73
|
+
// so two trees tie and resolveRules' deny-wins tiebreak decides.
|
|
74
|
+
const lastWild = Math.max(p.lastIndexOf("*"), p.lastIndexOf("?"));
|
|
75
|
+
const suffix = lastWild < 0 ? p : p.slice(lastWild + 1);
|
|
76
|
+
return suffix.replace(/[*?]/g, "").length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Most-specific matching rule wins, regardless of allow/deny. On an exact
|
|
81
|
+
* specificity tie, deny wins (fail closed). Returns undefined when nothing
|
|
82
|
+
* matches.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveRules(
|
|
85
|
+
rules: ReadonlyArray<{ pattern: string; state: "allow" | "deny" }>,
|
|
86
|
+
value: string,
|
|
87
|
+
): "allow" | "deny" | undefined {
|
|
88
|
+
let best: { state: "allow" | "deny"; score: number } | undefined;
|
|
89
|
+
for (const rule of rules) {
|
|
90
|
+
if (!matchGlob(rule.pattern, value)) continue;
|
|
91
|
+
const score = globSpecificity(rule.pattern);
|
|
92
|
+
if (
|
|
93
|
+
!best ||
|
|
94
|
+
score > best.score ||
|
|
95
|
+
// equal score → deny wins
|
|
96
|
+
(score === best.score && rule.state === "deny")
|
|
97
|
+
) {
|
|
98
|
+
best = { state: rule.state, score };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return best?.state;
|
|
102
|
+
}
|