@itc-steve/pi-ask-complete 0.2.0 → 1.0.1
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 +16 -8
- package/README.md +41 -124
- package/index.ts +6 -18
- package/package.json +4 -5
- package/src/ask-user-panel.ts +1 -1
- package/permission.json.example +0 -299
- package/src/base-command.ts +0 -287
- package/src/bash-scan.ts +0 -402
- package/src/describe-tool.ts +0 -141
- package/src/permission-panel.ts +0 -276
- package/src/permission-store.ts +0 -580
- package/src/permission.ts +0 -364
- package/src/wildcard.ts +0 -91
package/src/describe-tool.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
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/permission-panel.ts
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Single-shot permission panel (bottom slot, overlay:false).
|
|
3
|
-
* Four fixed options; Enter confirms immediately (no review tab).
|
|
4
|
-
* Deny opens a free-text editor for a reason.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
9
|
-
type Component,
|
|
10
|
-
Editor,
|
|
11
|
-
type EditorTheme,
|
|
12
|
-
type Focusable,
|
|
13
|
-
Key,
|
|
14
|
-
matchesKey,
|
|
15
|
-
truncateToWidth,
|
|
16
|
-
wrapTextWithAnsi,
|
|
17
|
-
} from "@earendil-works/pi-tui";
|
|
18
|
-
import type { PermissionPrompt } from "./describe-tool.ts";
|
|
19
|
-
import { withHerdrBlocked } from "./herdr-attention.ts";
|
|
20
|
-
import { padRight, sanitizeMultiline } from "./helpers.ts";
|
|
21
|
-
import {
|
|
22
|
-
ICON_CURSOR,
|
|
23
|
-
ICON_RADIO_EMPTY,
|
|
24
|
-
ICON_RADIO_FILLED,
|
|
25
|
-
TOGGLE_HINT,
|
|
26
|
-
TOGGLE_KEY,
|
|
27
|
-
type TuiLike,
|
|
28
|
-
} from "./types.ts";
|
|
29
|
-
|
|
30
|
-
export type { PermissionPrompt };
|
|
31
|
-
|
|
32
|
-
export type PermissionDecision =
|
|
33
|
-
| { action: "allow_once" }
|
|
34
|
-
| { action: "allow_session" }
|
|
35
|
-
| { action: "allow_always"; entry: string }
|
|
36
|
-
| { action: "deny"; reason: string }
|
|
37
|
-
| { action: "cancelled" };
|
|
38
|
-
|
|
39
|
-
type Opt = {
|
|
40
|
-
id: "once" | "session" | "always" | "deny";
|
|
41
|
-
label: string;
|
|
42
|
-
description: string;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
function optionsFor(prompt: PermissionPrompt): Opt[] {
|
|
46
|
-
return [
|
|
47
|
-
{
|
|
48
|
-
id: "once",
|
|
49
|
-
label: "Allow this",
|
|
50
|
-
description: "Permit this once. Ask again next time.",
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
id: "session",
|
|
54
|
-
label: prompt.session ?? "Allow for this session",
|
|
55
|
-
description: "Remember bases from this call until the agent session ends.",
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
id: "always",
|
|
59
|
-
label: prompt.permanent ?? "Allow permanently",
|
|
60
|
-
description: prompt.base
|
|
61
|
-
? `Whitelist \`${prompt.base}\` in permission.json. Survives restarts.`
|
|
62
|
-
: "Whitelist in permission.json. Survives restarts.",
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
id: "deny",
|
|
66
|
-
label: "Deny this with reason",
|
|
67
|
-
description: "Block. Opens a reason editor; empty reason still denies.",
|
|
68
|
-
},
|
|
69
|
-
];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
class PermissionPanel implements Component, Focusable {
|
|
73
|
-
focused = false;
|
|
74
|
-
private theme: Theme;
|
|
75
|
-
private tui: TuiLike;
|
|
76
|
-
private prompt: PermissionPrompt;
|
|
77
|
-
private opts: Opt[];
|
|
78
|
-
private cursor = 0;
|
|
79
|
-
private selected = -1;
|
|
80
|
-
private collapsed = false;
|
|
81
|
-
private inputMode = false;
|
|
82
|
-
private editor: Editor;
|
|
83
|
-
private onDone: (d: PermissionDecision) => void;
|
|
84
|
-
private cachedLines?: string[];
|
|
85
|
-
|
|
86
|
-
constructor(
|
|
87
|
-
prompt: PermissionPrompt,
|
|
88
|
-
tui: TuiLike,
|
|
89
|
-
theme: Theme,
|
|
90
|
-
onDone: (d: PermissionDecision) => void,
|
|
91
|
-
) {
|
|
92
|
-
this.prompt = prompt;
|
|
93
|
-
this.opts = optionsFor(prompt);
|
|
94
|
-
this.tui = tui;
|
|
95
|
-
this.theme = theme;
|
|
96
|
-
this.onDone = onDone;
|
|
97
|
-
|
|
98
|
-
const editorTheme: EditorTheme = {
|
|
99
|
-
borderColor: (s) => theme.fg("accent", s),
|
|
100
|
-
selectList: {
|
|
101
|
-
selectedPrefix: (t) => theme.fg("accent", t),
|
|
102
|
-
selectedText: (t) => theme.fg("accent", t),
|
|
103
|
-
description: (t) => theme.fg("muted", t),
|
|
104
|
-
scrollInfo: (t) => theme.fg("dim", t),
|
|
105
|
-
noMatch: (t) => theme.fg("warning", t),
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
this.editor = new Editor(tui as never, editorTheme);
|
|
109
|
-
this.editor.onSubmit = (value) => {
|
|
110
|
-
const reason = value.trim() || "Denied by user";
|
|
111
|
-
this.onDone({ action: "deny", reason });
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private refresh(): void {
|
|
116
|
-
this.cachedLines = undefined;
|
|
117
|
-
this.tui.requestRender();
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
private finish(opt: Opt): void {
|
|
121
|
-
if (opt.id === "once") {
|
|
122
|
-
this.onDone({ action: "allow_once" });
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (opt.id === "session") {
|
|
126
|
-
this.onDone({ action: "allow_session" });
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
if (opt.id === "always") {
|
|
130
|
-
this.onDone({ action: "allow_always", entry: this.prompt.base });
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
this.selected = this.cursor;
|
|
134
|
-
this.inputMode = true;
|
|
135
|
-
this.refresh();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
handleInput(data: string): void {
|
|
139
|
-
if (matchesKey(data, TOGGLE_KEY)) {
|
|
140
|
-
this.collapsed = !this.collapsed;
|
|
141
|
-
this.refresh();
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (this.collapsed) {
|
|
146
|
-
if (matchesKey(data, Key.escape)) this.onDone({ action: "cancelled" });
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (this.inputMode) {
|
|
151
|
-
if (matchesKey(data, Key.escape)) {
|
|
152
|
-
this.inputMode = false;
|
|
153
|
-
this.editor.setText("");
|
|
154
|
-
this.refresh();
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
this.editor.handleInput(data);
|
|
158
|
-
this.refresh();
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (matchesKey(data, Key.escape)) {
|
|
163
|
-
this.onDone({ action: "cancelled" });
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (matchesKey(data, Key.up)) {
|
|
167
|
-
this.cursor = Math.max(0, this.cursor - 1);
|
|
168
|
-
this.refresh();
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (matchesKey(data, Key.down)) {
|
|
172
|
-
this.cursor = Math.min(this.opts.length - 1, this.cursor + 1);
|
|
173
|
-
this.refresh();
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
if (matchesKey(data, Key.space) || matchesKey(data, Key.enter)) {
|
|
177
|
-
this.selected = this.cursor;
|
|
178
|
-
this.finish(this.opts[this.cursor]!);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
render(width: number): string[] {
|
|
183
|
-
if (this.cachedLines) return this.cachedLines;
|
|
184
|
-
const th = this.theme;
|
|
185
|
-
const innerW = Math.max(20, width - 2);
|
|
186
|
-
// Hard-clamp every content cell — long python -c / base lists must never
|
|
187
|
-
// exceed terminal width (pi TUI throws on overflow).
|
|
188
|
-
const row = (s: string) => {
|
|
189
|
-
const fitted = truncateToWidth(s, innerW);
|
|
190
|
-
return th.fg("border", "│") + padRight(fitted, innerW) + th.fg("border", "│");
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
if (this.collapsed) {
|
|
194
|
-
this.cachedLines = [
|
|
195
|
-
th.fg("border", `╭${"─".repeat(innerW)}╮`),
|
|
196
|
-
row(
|
|
197
|
-
` ${th.fg("warning", "⏸")} ${th.fg("accent", "Permission")} ${th.fg("dim", `· ${TOGGLE_HINT} expand · Esc cancel`)}`,
|
|
198
|
-
),
|
|
199
|
-
th.fg("border", `╰${"─".repeat(innerW)}╯`),
|
|
200
|
-
];
|
|
201
|
-
return this.cachedLines;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const lines: string[] = [];
|
|
205
|
-
lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
|
|
206
|
-
lines.push(
|
|
207
|
-
row(
|
|
208
|
-
` ${th.fg("accent", th.bold("Permission required"))}${th.fg("dim", ` · ${this.prompt.base}`)}`,
|
|
209
|
-
),
|
|
210
|
-
);
|
|
211
|
-
lines.push(row(""));
|
|
212
|
-
lines.push(row(` ${th.fg("muted", "The agent wants to:")}`));
|
|
213
|
-
const display = sanitizeMultiline(this.prompt.display).replace(/\s+/g, " ").trim();
|
|
214
|
-
for (const w of wrapTextWithAnsi(th.fg("text", ` ${display}`), Math.max(8, innerW - 2))) {
|
|
215
|
-
lines.push(row(` ${w}`));
|
|
216
|
-
}
|
|
217
|
-
if (this.prompt.detail?.trim()) {
|
|
218
|
-
lines.push(row(""));
|
|
219
|
-
for (const w of wrapTextWithAnsi(
|
|
220
|
-
th.fg("dim", this.prompt.detail.trim()),
|
|
221
|
-
Math.max(8, innerW - 2),
|
|
222
|
-
)) {
|
|
223
|
-
lines.push(row(` ${w}`));
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
lines.push(row(""));
|
|
227
|
-
|
|
228
|
-
if (this.inputMode) {
|
|
229
|
-
lines.push(row(` ${th.fg("warning", "Reason for denial:")}`));
|
|
230
|
-
for (const el of this.editor.render(Math.max(8, innerW - 2))) lines.push(row(` ${el}`));
|
|
231
|
-
lines.push(row(th.fg("dim", " Enter submit · Esc back")));
|
|
232
|
-
} else {
|
|
233
|
-
for (let i = 0; i < this.opts.length; i++) {
|
|
234
|
-
const opt = this.opts[i]!;
|
|
235
|
-
const isCursor = i === this.cursor;
|
|
236
|
-
const isSel = i === this.selected;
|
|
237
|
-
const glyph = isSel
|
|
238
|
-
? th.fg("success", ICON_RADIO_FILLED)
|
|
239
|
-
: th.fg("dim", ICON_RADIO_EMPTY);
|
|
240
|
-
const cur = isCursor ? th.fg("accent", ICON_CURSOR) : " ";
|
|
241
|
-
// Truncate label text before coloring so bold/fg don't hide overflow.
|
|
242
|
-
const labelMax = Math.max(4, innerW - 6);
|
|
243
|
-
const labelPlain = truncateToWidth(opt.label, labelMax, "");
|
|
244
|
-
const label = isCursor
|
|
245
|
-
? th.fg("accent", th.bold(labelPlain))
|
|
246
|
-
: th.fg("text", labelPlain);
|
|
247
|
-
lines.push(row(` ${cur} ${glyph} ${label}`));
|
|
248
|
-
const desc = truncateToWidth(opt.description, Math.max(4, innerW - 6), "");
|
|
249
|
-
lines.push(row(` ${th.fg("muted", desc)}`));
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
|
|
254
|
-
lines.push(
|
|
255
|
-
row(th.fg("dim", ` ${TOGGLE_HINT} collapse · ↑↓ move · Enter confirm · Esc cancel`)),
|
|
256
|
-
);
|
|
257
|
-
lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
|
|
258
|
-
this.cachedLines = lines;
|
|
259
|
-
return lines;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export async function runPermissionPanel(
|
|
264
|
-
ctx: Pick<ExtensionContext, "ui">,
|
|
265
|
-
prompt: PermissionPrompt,
|
|
266
|
-
): Promise<PermissionDecision> {
|
|
267
|
-
return withHerdrBlocked("Permission needed", async () => {
|
|
268
|
-
const result = await ctx.ui.custom<PermissionDecision>(
|
|
269
|
-
(tui, theme, _kb, done) => {
|
|
270
|
-
return new PermissionPanel(prompt, tui as TuiLike, theme, done);
|
|
271
|
-
},
|
|
272
|
-
{ overlay: false },
|
|
273
|
-
);
|
|
274
|
-
return result ?? { action: "cancelled" };
|
|
275
|
-
});
|
|
276
|
-
}
|