@pinchpass/cli 1.1.1 → 1.2.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 +28 -4
- package/dist/args.js +30 -0
- package/dist/binary.js +124 -0
- package/dist/env.js +62 -0
- package/dist/index.js +317 -269
- package/dist/json-stream.js +48 -0
- package/package.json +14 -9
- package/scripts/install-binary.js +189 -75
- package/skills/pinchpass/SKILL.md +23 -4
package/dist/index.js
CHANGED
|
@@ -1,126 +1,64 @@
|
|
|
1
|
+
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import { Type } from "typebox";
|
|
2
3
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
|
-
import {
|
|
4
|
+
import { CURSOR_MARKER, Key, Text, matchesKey, renderFakeCursor, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { buildArgs, chooseVia } from "./args.js";
|
|
8
|
+
import { BINARY_MISSING, findBinary, updateNotice } from "./binary.js";
|
|
9
|
+
import { writeEnvKey } from "./env.js";
|
|
10
|
+
import { takeJsonBlobs } from "./json-stream.js";
|
|
8
11
|
/** Small figlet "small" font rendering of PINCHPASS. */
|
|
9
12
|
const PINCHPASS_ART = " ___ ___ _ _ ___ _ _ ___ _ ___ ___ \n" +
|
|
10
13
|
" | _ \\_ _| \\| |/ __| || | _ \\/_\\ / __/ __|\n" +
|
|
11
14
|
" | _/| || .` | (__| __ | _/ _ \\\\__ \\__ \\\n" +
|
|
12
15
|
" |_| |___|_|\\_|\\___|_||_|_|/_/ \\_\\___/___/";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
const BINARY_MISSING = `The pinchpass binary is not installed. Install it with one of:
|
|
36
|
-
|
|
37
|
-
npm i -g @pinchpass/cli # downloads the prebuilt binary for your platform
|
|
38
|
-
go install github.com/rubybear-lgtm/pinchpass@latest
|
|
39
|
-
|
|
40
|
-
Then try request_secret again.`;
|
|
41
|
-
/** Pull complete JSON objects out of a (possibly pretty-printed) stream. */
|
|
42
|
-
function takeJsonBlobs(text) {
|
|
43
|
-
const blobs = [];
|
|
44
|
-
let depth = 0;
|
|
45
|
-
let start = -1;
|
|
46
|
-
let inString = false;
|
|
47
|
-
let escaped = false;
|
|
48
|
-
for (let i = 0; i < text.length; i++) {
|
|
49
|
-
const c = text[i];
|
|
50
|
-
if (inString) {
|
|
51
|
-
if (escaped)
|
|
52
|
-
escaped = false;
|
|
53
|
-
else if (c === "\\")
|
|
54
|
-
escaped = true;
|
|
55
|
-
else if (c === '"')
|
|
56
|
-
inString = false;
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
if (c === '"') {
|
|
60
|
-
inString = true;
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
if (c === "{") {
|
|
64
|
-
if (depth === 0)
|
|
65
|
-
start = i;
|
|
66
|
-
depth++;
|
|
67
|
-
}
|
|
68
|
-
else if (c === "}") {
|
|
69
|
-
depth--;
|
|
70
|
-
if (depth === 0 && start >= 0) {
|
|
71
|
-
try {
|
|
72
|
-
blobs.push(JSON.parse(text.slice(start, i + 1)));
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
// skip malformed output
|
|
76
|
-
}
|
|
77
|
-
start = -1;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return { blobs, rest: start >= 0 ? text.slice(start) : "" };
|
|
16
|
+
const RESULT_SCHEMA = Type.Object({
|
|
17
|
+
success: Type.Boolean(),
|
|
18
|
+
aborted: Type.Boolean(),
|
|
19
|
+
via: StringEnum(["modal", "link"]),
|
|
20
|
+
names: Type.Array(Type.String()),
|
|
21
|
+
saved: Type.Array(Type.String()),
|
|
22
|
+
out: Type.Optional(Type.String()),
|
|
23
|
+
url: Type.Optional(Type.String()),
|
|
24
|
+
port: Type.Optional(Type.Number()),
|
|
25
|
+
message: Type.Optional(Type.String()),
|
|
26
|
+
error: Type.Optional(Type.String()),
|
|
27
|
+
});
|
|
28
|
+
function makeResult(details, text, isError = false) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text }],
|
|
31
|
+
details,
|
|
32
|
+
structuredContent: { ...details },
|
|
33
|
+
isError,
|
|
34
|
+
};
|
|
82
35
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
let existing = "";
|
|
92
|
-
try {
|
|
93
|
-
existing = readFileSync(path, "utf8");
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
// new file
|
|
97
|
-
}
|
|
98
|
-
const out = [];
|
|
99
|
-
let replaced = false;
|
|
100
|
-
for (const l of existing.split("\n")) {
|
|
101
|
-
const t = l.trim();
|
|
102
|
-
if (t === "")
|
|
103
|
-
continue;
|
|
104
|
-
if (t.startsWith(key + "=")) {
|
|
105
|
-
out.push(line);
|
|
106
|
-
replaced = true;
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
out.push(l);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
if (!replaced)
|
|
113
|
-
out.push(line);
|
|
114
|
-
writeFileSync(path, out.join("\n") + "\n", { mode: 0o600 });
|
|
36
|
+
const localFileQueues = new Map();
|
|
37
|
+
/** Fallback used when the host does not expose `withFileMutationQueue`. */
|
|
38
|
+
function localFileQueue(filePath, fn) {
|
|
39
|
+
const key = resolve(filePath);
|
|
40
|
+
const previous = localFileQueues.get(key) ?? Promise.resolve();
|
|
41
|
+
const run = previous.then(fn, fn);
|
|
42
|
+
localFileQueues.set(key, run.catch(() => undefined));
|
|
43
|
+
return run;
|
|
115
44
|
}
|
|
45
|
+
const withFileQueue = typeof piCodingAgent.withFileMutationQueue === "function"
|
|
46
|
+
? piCodingAgent.withFileMutationQueue
|
|
47
|
+
: localFileQueue;
|
|
116
48
|
/**
|
|
117
49
|
* Branded masked secret dialog (Pi TUI overlay). The value is rendered as
|
|
118
|
-
* bullets, never echoed, and submitted straight to the caller.
|
|
50
|
+
* bullets, never echoed, and submitted straight to the caller. Handles
|
|
51
|
+
* bracketed paste and terminal escape sequences explicitly.
|
|
119
52
|
*/
|
|
120
53
|
async function promptSecretTUI(ctx, name, note, signal) {
|
|
121
|
-
return ctx.ui.custom((tui, theme,
|
|
54
|
+
return ctx.ui.custom((tui, theme, kb, done) => {
|
|
122
55
|
let value = "";
|
|
123
56
|
let alive = true;
|
|
57
|
+
let pasteBuffer = "";
|
|
58
|
+
let isInPaste = false;
|
|
59
|
+
let cachedLines;
|
|
60
|
+
let cachedWidth = 0;
|
|
61
|
+
let cachedValueLength = -1;
|
|
124
62
|
const finish = (v) => {
|
|
125
63
|
if (!alive)
|
|
126
64
|
return;
|
|
@@ -131,71 +69,116 @@ async function promptSecretTUI(ctx, name, note, signal) {
|
|
|
131
69
|
};
|
|
132
70
|
const onAbort = () => finish(undefined);
|
|
133
71
|
if (signal) {
|
|
134
|
-
if (signal.aborted)
|
|
72
|
+
if (signal.aborted)
|
|
135
73
|
onAbort();
|
|
136
|
-
|
|
137
|
-
else {
|
|
74
|
+
else
|
|
138
75
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
139
|
-
}
|
|
140
76
|
}
|
|
77
|
+
const refresh = () => {
|
|
78
|
+
cachedLines = undefined;
|
|
79
|
+
tui.requestRender(true);
|
|
80
|
+
};
|
|
141
81
|
const comp = {
|
|
142
82
|
focused: true,
|
|
143
83
|
handleInput(data) {
|
|
144
84
|
if (!alive)
|
|
145
85
|
return;
|
|
146
|
-
//
|
|
147
|
-
if (data.
|
|
86
|
+
// Bracketed paste: accumulate until the end marker, then insert.
|
|
87
|
+
if (data.includes("\u001b[200~")) {
|
|
88
|
+
isInPaste = true;
|
|
89
|
+
pasteBuffer = "";
|
|
90
|
+
data = data.replace("\u001b[200~", "");
|
|
91
|
+
}
|
|
92
|
+
if (isInPaste) {
|
|
93
|
+
pasteBuffer += data;
|
|
94
|
+
const end = pasteBuffer.indexOf("\u001b[201~");
|
|
95
|
+
if (end === -1) {
|
|
96
|
+
refresh();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const pasted = pasteBuffer.slice(0, end);
|
|
100
|
+
const remaining = pasteBuffer.slice(end + 6);
|
|
101
|
+
pasteBuffer = "";
|
|
102
|
+
isInPaste = false;
|
|
103
|
+
for (const ch of pasted) {
|
|
104
|
+
if (ch >= " " && ch !== "\u007f")
|
|
105
|
+
value += ch;
|
|
106
|
+
}
|
|
107
|
+
if (remaining)
|
|
108
|
+
comp.handleInput?.(remaining);
|
|
109
|
+
else
|
|
110
|
+
refresh();
|
|
148
111
|
return;
|
|
149
|
-
|
|
112
|
+
}
|
|
113
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, Key.escape)) {
|
|
150
114
|
finish(undefined);
|
|
151
115
|
return;
|
|
152
116
|
}
|
|
117
|
+
if (kb.matches(data, "tui.input.submit") || matchesKey(data, Key.enter) || data === "\n") {
|
|
118
|
+
finish(value);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (matchesKey(data, Key.backspace) || matchesKey(data, Key.delete)) {
|
|
122
|
+
value = value.slice(0, -1);
|
|
123
|
+
refresh();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Ignore unhandled escape sequences (arrows, home/end, …) as a chunk.
|
|
127
|
+
if (data.startsWith("\u001b")) {
|
|
128
|
+
refresh();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
153
131
|
for (const ch of data) {
|
|
154
|
-
if (ch
|
|
155
|
-
finish(value);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
if (ch === "\u0003") {
|
|
159
|
-
finish(undefined);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
if (ch === "\u007f" || ch === "\b") {
|
|
163
|
-
value = value.slice(0, -1);
|
|
164
|
-
}
|
|
165
|
-
else if (ch >= " " && ch !== "\u007f") {
|
|
132
|
+
if (ch >= " " && ch !== "\u007f")
|
|
166
133
|
value += ch;
|
|
167
|
-
}
|
|
168
134
|
}
|
|
169
|
-
|
|
135
|
+
refresh();
|
|
136
|
+
},
|
|
137
|
+
invalidate() {
|
|
138
|
+
cachedLines = undefined;
|
|
170
139
|
},
|
|
171
|
-
invalidate() { },
|
|
172
140
|
render(width) {
|
|
141
|
+
if (cachedLines && cachedWidth === width && cachedValueLength === value.length)
|
|
142
|
+
return cachedLines;
|
|
143
|
+
const renderWidth = Math.max(1, width);
|
|
173
144
|
const art = PINCHPASS_ART.split("\n");
|
|
174
|
-
const
|
|
175
|
-
const
|
|
145
|
+
const artWidth = art.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
|
|
146
|
+
const boxWidth = Math.min(Math.max(artWidth + 4, 40), Math.max(20, renderWidth - 2));
|
|
147
|
+
const innerWidth = Math.max(4, boxWidth - 4);
|
|
148
|
+
const leftPad = " ".repeat(Math.max(0, Math.floor((renderWidth - boxWidth) / 2)));
|
|
149
|
+
const border = (s) => theme.fg("borderAccent", s);
|
|
150
|
+
const boxed = (content) => {
|
|
151
|
+
const clipped = truncateToWidth(content, innerWidth, "");
|
|
152
|
+
const fill = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
153
|
+
return `${leftPad}${border("│")} ${clipped}${fill} ${border("│")}`;
|
|
154
|
+
};
|
|
155
|
+
const centered = (raw) => {
|
|
156
|
+
const w = visibleWidth(raw);
|
|
157
|
+
if (w >= innerWidth)
|
|
158
|
+
return raw;
|
|
159
|
+
const left = Math.floor((innerWidth - w) / 2);
|
|
160
|
+
return " ".repeat(left) + raw + " ".repeat(innerWidth - w - left);
|
|
161
|
+
};
|
|
176
162
|
const lines = [];
|
|
177
163
|
lines.push("");
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
lines.push(
|
|
164
|
+
lines.push(`${leftPad}${border(`╭${"─".repeat(Math.max(0, boxWidth - 2))}╮`)}`);
|
|
165
|
+
for (const a of art)
|
|
166
|
+
lines.push(boxed(centered(theme.fg("muted", a))));
|
|
167
|
+
lines.push(boxed(""));
|
|
168
|
+
lines.push(boxed(theme.fg("toolTitle", theme.bold(` 🔐 ${name}`))));
|
|
182
169
|
if (note)
|
|
183
|
-
lines.push(
|
|
184
|
-
lines.push("");
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
const cursor =
|
|
188
|
-
|
|
189
|
-
lines.push("
|
|
190
|
-
lines.push(" ".
|
|
191
|
-
theme.fg("borderAccent", "│") +
|
|
192
|
-
theme.fg("accent", inner) +
|
|
193
|
-
theme.fg("borderAccent", "│"));
|
|
194
|
-
lines.push(" ".repeat(pad) + theme.fg("borderAccent", "└" + "─".repeat(boxW - 2) + "┘"));
|
|
170
|
+
lines.push(boxed(theme.fg("dim", ` ${note}`)));
|
|
171
|
+
lines.push(boxed(""));
|
|
172
|
+
const maxDots = Math.max(1, innerWidth - 3);
|
|
173
|
+
const dots = "•".repeat(Math.min(value.length, maxDots));
|
|
174
|
+
const cursor = `${CURSOR_MARKER}${renderFakeCursor(theme.fg("accent", " "))}`;
|
|
175
|
+
lines.push(boxed(` ${theme.fg("accent", dots)}${cursor}`));
|
|
176
|
+
lines.push(`${leftPad}${border(`╰${"─".repeat(Math.max(0, boxWidth - 2))}╯`)}`);
|
|
177
|
+
lines.push(boxed(`${theme.fg("dim", "enter submit")}${theme.fg("muted", " esc cancel")}`));
|
|
195
178
|
lines.push("");
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
179
|
+
cachedLines = lines;
|
|
180
|
+
cachedWidth = width;
|
|
181
|
+
cachedValueLength = value.length;
|
|
199
182
|
return lines;
|
|
200
183
|
},
|
|
201
184
|
};
|
|
@@ -205,71 +188,64 @@ async function promptSecretTUI(ctx, name, note, signal) {
|
|
|
205
188
|
overlayOptions: { width: "60%", minWidth: 54, maxHeight: "65%" },
|
|
206
189
|
});
|
|
207
190
|
}
|
|
191
|
+
/** Open a URL in the user's browser (best effort; loopback links only). */
|
|
192
|
+
function openInBrowser(url) {
|
|
193
|
+
if (process.platform === "darwin") {
|
|
194
|
+
const p = spawn("open", [url], { stdio: "ignore", detached: true });
|
|
195
|
+
p.on("error", () => { });
|
|
196
|
+
p.unref();
|
|
197
|
+
}
|
|
198
|
+
else if (process.platform === "win32") {
|
|
199
|
+
const p = spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true });
|
|
200
|
+
p.on("error", () => { });
|
|
201
|
+
p.unref();
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const p = spawn("xdg-open", [url], { stdio: "ignore", detached: true });
|
|
205
|
+
p.on("error", () => { });
|
|
206
|
+
p.unref();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Auto-open a link only when it is reachable from this machine. */
|
|
210
|
+
function maybeOpenBrowser(url, mode) {
|
|
211
|
+
if (mode !== "tui")
|
|
212
|
+
return;
|
|
213
|
+
if (process.env.PINCHPASS_NO_OPEN)
|
|
214
|
+
return;
|
|
215
|
+
if (!/^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):/.test(url))
|
|
216
|
+
return;
|
|
217
|
+
try {
|
|
218
|
+
openInBrowser(url);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// best effort — the link is still rendered in the TUI
|
|
222
|
+
}
|
|
223
|
+
}
|
|
208
224
|
/**
|
|
209
|
-
* Collect secrets directly in the Pi
|
|
210
|
-
* straight from the user's keyboard into the .env file — it is never sent
|
|
211
|
-
* the LLM, never enters the session transcript, and no server is started.
|
|
225
|
+
* Collect secrets directly in the Pi TUI via masked modal dialogs. Each value
|
|
226
|
+
* goes straight from the user's keyboard into the .env file — it is never sent
|
|
227
|
+
* to the LLM, never enters the session transcript, and no server is started.
|
|
212
228
|
*/
|
|
213
229
|
async function collectViaModal(ctx, params, signal) {
|
|
214
230
|
const outPath = resolve(ctx.cwd, params.out ?? ".env");
|
|
231
|
+
const ignored = [];
|
|
232
|
+
if (typeof params.ttl === "number")
|
|
233
|
+
ignored.push("ttl (link mode only)");
|
|
234
|
+
const ignoreNote = ignored.length ? ` (ignored in modal mode: ${ignored.join(", ")})` : "";
|
|
215
235
|
const saved = [];
|
|
216
236
|
for (const name of params.names) {
|
|
217
|
-
const value = ctx.
|
|
218
|
-
? await promptSecretTUI(ctx, name, params.note ?? "", signal)
|
|
219
|
-
: await ctx.ui.input(`Enter ${name}`, params.note ?? `Secret value for ${name}`, {
|
|
220
|
-
signal,
|
|
221
|
-
});
|
|
237
|
+
const value = await promptSecretTUI(ctx, name, params.note ?? "", signal);
|
|
222
238
|
if (value === undefined) {
|
|
223
|
-
return {
|
|
224
|
-
content: [
|
|
225
|
-
{
|
|
226
|
-
type: "text",
|
|
227
|
-
text: `Secret request cancelled — ${name} not provided.${saved.length ? ` (${saved.join(", ")} already saved)` : ""} No further secrets were collected.`,
|
|
228
|
-
},
|
|
229
|
-
],
|
|
230
|
-
details: { success: false, names: params.names, saved, aborted: true },
|
|
231
|
-
};
|
|
239
|
+
return makeResult({ success: false, aborted: true, via: "modal", names: params.names, saved, out: outPath, error: "cancelled" }, `Secret request cancelled — ${name} not provided.${saved.length ? ` (${saved.join(", ")} already saved)` : ""} No further secrets were collected.`);
|
|
232
240
|
}
|
|
233
241
|
const trimmed = value.trim();
|
|
234
242
|
if (!trimmed) {
|
|
235
|
-
return {
|
|
236
|
-
content: [
|
|
237
|
-
{
|
|
238
|
-
type: "text",
|
|
239
|
-
text: `Secret request cancelled — ${name} was empty.${saved.length ? ` (${saved.join(", ")} already saved)` : ""}`,
|
|
240
|
-
},
|
|
241
|
-
],
|
|
242
|
-
details: { success: false, names: params.names, saved, aborted: true },
|
|
243
|
-
};
|
|
243
|
+
return makeResult({ success: false, aborted: true, via: "modal", names: params.names, saved, out: outPath, error: "empty" }, `Secret request cancelled — ${name} was empty.${saved.length ? ` (${saved.join(", ")} already saved)` : ""}`);
|
|
244
244
|
}
|
|
245
|
-
writeEnvKey(outPath, name, trimmed);
|
|
245
|
+
await withFileQueue(outPath, async () => writeEnvKey(outPath, name, trimmed));
|
|
246
246
|
saved.push(name);
|
|
247
247
|
}
|
|
248
|
-
return {
|
|
249
|
-
content: [
|
|
250
|
-
{ type: "text", text: `✅ Secrets collected via Pi modal: ${saved.join(", ")}\nSaved to ${outPath}` },
|
|
251
|
-
],
|
|
252
|
-
details: {
|
|
253
|
-
success: true,
|
|
254
|
-
names: saved,
|
|
255
|
-
message: "Secrets collected via Pi modal.",
|
|
256
|
-
url: undefined,
|
|
257
|
-
out: outPath,
|
|
258
|
-
aborted: false,
|
|
259
|
-
},
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
function buildArgs(params) {
|
|
263
|
-
const args = ["request", ...params.names, "-json"];
|
|
264
|
-
if (params.tunnel)
|
|
265
|
-
args.push("-tunnel");
|
|
266
|
-
if (params.note)
|
|
267
|
-
args.push("-note", params.note);
|
|
268
|
-
if (typeof params.ttl === "number")
|
|
269
|
-
args.push("-ttl", String(params.ttl));
|
|
270
|
-
if (params.out)
|
|
271
|
-
args.push("-out", params.out);
|
|
272
|
-
return args;
|
|
248
|
+
return makeResult({ success: true, aborted: false, via: "modal", names: params.names, saved, out: outPath }, `✅ Secrets collected via Pi modal: ${saved.join(", ")}\nSaved to ${outPath}${ignoreNote}`);
|
|
273
249
|
}
|
|
274
250
|
/* ------------------------------------------------------------------ */
|
|
275
251
|
/* Pi native tool */
|
|
@@ -286,7 +262,7 @@ function registerPiTool(pi) {
|
|
|
286
262
|
promptGuidelines: [
|
|
287
263
|
"Use request_secret when you need an API key, token, or password from the user — never ask them to paste secrets directly into chat.",
|
|
288
264
|
"request_secret streams the link (or shows the modal) to the user immediately; wait for its result before continuing.",
|
|
289
|
-
"Use request_secret with tunnel: true when the user is not on the same machine as this agent.",
|
|
265
|
+
"Use request_secret with tunnel: true when the user is not on the same machine as this agent — tunnel always uses the encrypted browser link, never the modal.",
|
|
290
266
|
"Use request_secret with via: 'link' to force the encrypted browser link, or via: 'modal' to force the Pi dialog.",
|
|
291
267
|
],
|
|
292
268
|
parameters: Type.Object({
|
|
@@ -302,31 +278,44 @@ function registerPiTool(pi) {
|
|
|
302
278
|
description: "How to collect: 'modal' prompts in the Pi UI, 'link' generates the encrypted browser link, 'auto' picks modal in the TUI and link elsewhere (default: auto)",
|
|
303
279
|
})),
|
|
304
280
|
}),
|
|
281
|
+
outputSchema: RESULT_SCHEMA,
|
|
282
|
+
executionMode: "sequential",
|
|
305
283
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
306
|
-
const via = params.via
|
|
307
|
-
const useModal = via === "modal"
|
|
284
|
+
const { via, downgraded } = chooseVia(params.via, params.tunnel, ctx.mode);
|
|
285
|
+
const useModal = via === "modal";
|
|
286
|
+
if (useModal && !ctx.hasUI) {
|
|
287
|
+
return makeResult({
|
|
288
|
+
success: false,
|
|
289
|
+
aborted: false,
|
|
290
|
+
via: "modal",
|
|
291
|
+
names: params.names,
|
|
292
|
+
saved: [],
|
|
293
|
+
error: "no-ui",
|
|
294
|
+
}, "Modal collection is unavailable here (no interactive UI in this mode). Call request_secret again with via: 'link' to use the encrypted browser link.", true);
|
|
295
|
+
}
|
|
308
296
|
if (useModal) {
|
|
309
|
-
if (!ctx.hasUI) {
|
|
310
|
-
return {
|
|
311
|
-
content: [
|
|
312
|
-
{
|
|
313
|
-
type: "text",
|
|
314
|
-
text: "Modal collection is unavailable here (no interactive UI in this mode). Call request_secret again with via: 'link' to use the encrypted browser link.",
|
|
315
|
-
},
|
|
316
|
-
],
|
|
317
|
-
details: { success: false, names: params.names, error: "no-ui" },
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
297
|
return collectViaModal(ctx, params, signal);
|
|
321
298
|
}
|
|
299
|
+
const downgradeNote = downgraded
|
|
300
|
+
? " (modal needs the interactive TUI; used the encrypted browser link instead)"
|
|
301
|
+
: "";
|
|
322
302
|
const binary = findBinary();
|
|
323
303
|
if (!binary) {
|
|
324
|
-
|
|
304
|
+
return makeResult({
|
|
305
|
+
success: false,
|
|
306
|
+
aborted: false,
|
|
307
|
+
via: "link",
|
|
308
|
+
names: params.names,
|
|
309
|
+
saved: [],
|
|
310
|
+
error: "binary-missing",
|
|
311
|
+
message: "pinchpass binary not found",
|
|
312
|
+
}, BINARY_MISSING, true);
|
|
325
313
|
}
|
|
326
314
|
const child = spawn(binary, buildArgs(params), {
|
|
327
315
|
cwd: ctx.cwd,
|
|
328
316
|
stdio: ["ignore", "pipe", "pipe"],
|
|
329
317
|
});
|
|
318
|
+
const notice = updateNotice(binary);
|
|
330
319
|
let buffer = "";
|
|
331
320
|
let stderr = "";
|
|
332
321
|
const blobs = [];
|
|
@@ -358,8 +347,18 @@ function registerPiTool(pi) {
|
|
|
358
347
|
text: `🔗 One-time E2E-encrypted secret request link${ttlNote}:\n${b.url}\n\nAsk the user to open it and submit ${(b.names ?? []).join(", ") || "the secret"}.`,
|
|
359
348
|
},
|
|
360
349
|
],
|
|
361
|
-
details: { url: b.url, port: b.port, names: b.names ?? [] },
|
|
350
|
+
details: { url: b.url, port: b.port, names: b.names ?? [], via: "link" },
|
|
362
351
|
});
|
|
352
|
+
if (ctx.mode === "tui") {
|
|
353
|
+
const widget = [
|
|
354
|
+
`🔗 pinchpass — open this link to submit ${(b.names ?? []).join(", ") || "the secret"}:`,
|
|
355
|
+
b.url,
|
|
356
|
+
];
|
|
357
|
+
if (notice)
|
|
358
|
+
widget.push(`⚠ ${notice}`);
|
|
359
|
+
ctx.ui.setWidget("pinchpass-link", widget);
|
|
360
|
+
}
|
|
361
|
+
maybeOpenBrowser(b.url, ctx.mode);
|
|
363
362
|
}
|
|
364
363
|
}
|
|
365
364
|
});
|
|
@@ -369,49 +368,96 @@ function registerPiTool(pi) {
|
|
|
369
368
|
child.on("error", (err) => reject(err));
|
|
370
369
|
child.on("close", (code) => resolve(code));
|
|
371
370
|
});
|
|
372
|
-
|
|
373
|
-
return {
|
|
374
|
-
content: [
|
|
375
|
-
{
|
|
376
|
-
type: "text",
|
|
377
|
-
text: `Secret request cancelled (${params.names.join(", ")}). No value was saved.`,
|
|
378
|
-
},
|
|
379
|
-
],
|
|
380
|
-
details: { success: false, names: params.names, aborted: true },
|
|
381
|
-
};
|
|
382
|
-
}
|
|
371
|
+
const out = resolve(ctx.cwd, params.out ?? ".env");
|
|
383
372
|
const firstBlob = blobs[0];
|
|
384
373
|
const lastBlob = blobs[blobs.length - 1];
|
|
374
|
+
// The CLI prints blob 1 before waiting and blob 2 after. A hard failure
|
|
375
|
+
// (decrypt/save error) exits non-zero with only blob 1, so a single
|
|
376
|
+
// blob is never a completed request.
|
|
377
|
+
const completed = exitCode === 0 && blobs.length > 1 && lastBlob?.success === true;
|
|
378
|
+
if (signal?.aborted) {
|
|
379
|
+
if (completed) {
|
|
380
|
+
const names = lastBlob.names ?? params.names;
|
|
381
|
+
return makeResult({
|
|
382
|
+
success: true,
|
|
383
|
+
aborted: false,
|
|
384
|
+
via: "link",
|
|
385
|
+
names,
|
|
386
|
+
saved: names,
|
|
387
|
+
out,
|
|
388
|
+
url: firstBlob?.url,
|
|
389
|
+
message: lastBlob.message,
|
|
390
|
+
port: lastBlob.port,
|
|
391
|
+
}, `✅ Secrets collected: ${names.join(", ")}\nSaved to ${params.out ?? ".env"} in ${ctx.cwd}`);
|
|
392
|
+
}
|
|
393
|
+
return makeResult({
|
|
394
|
+
success: false,
|
|
395
|
+
aborted: true,
|
|
396
|
+
via: "link",
|
|
397
|
+
names: params.names,
|
|
398
|
+
saved: [],
|
|
399
|
+
error: "cancelled",
|
|
400
|
+
}, `Secret request cancelled (${params.names.join(", ")}). No value was saved.`);
|
|
401
|
+
}
|
|
385
402
|
if (!lastBlob) {
|
|
386
|
-
|
|
403
|
+
return makeResult({
|
|
404
|
+
success: false,
|
|
405
|
+
aborted: false,
|
|
406
|
+
via: "link",
|
|
407
|
+
names: params.names,
|
|
408
|
+
saved: [],
|
|
409
|
+
error: "no-output",
|
|
410
|
+
message: `pinchpass exited without JSON output (code ${exitCode})`,
|
|
411
|
+
}, `pinchpass exited without JSON output (code ${exitCode}).${stderr ? `\n${stderr}` : ""}`, true);
|
|
387
412
|
}
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
?
|
|
391
|
-
:
|
|
413
|
+
const names = lastBlob.names ?? params.names;
|
|
414
|
+
const failureMessage = exitCode !== 0
|
|
415
|
+
? stderr.trim() || `pinchpass exited with code ${exitCode}`
|
|
416
|
+
: lastBlob.message ?? "Secret request failed.";
|
|
417
|
+
const lines = completed
|
|
418
|
+
? [`✅ Secrets collected: ${names.join(", ")}`]
|
|
419
|
+
: [`❌ ${failureMessage}`];
|
|
392
420
|
if (firstBlob?.url)
|
|
393
421
|
lines.push(`Link: ${firstBlob.url}`);
|
|
394
|
-
if (
|
|
422
|
+
if (completed)
|
|
395
423
|
lines.push(`Saved to ${params.out ?? ".env"} in ${ctx.cwd}`);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
424
|
+
if (downgradeNote)
|
|
425
|
+
lines.push(downgradeNote.trim());
|
|
426
|
+
if (notice)
|
|
427
|
+
lines.push(`⚠ ${notice}`);
|
|
428
|
+
return makeResult({
|
|
429
|
+
success: completed,
|
|
430
|
+
aborted: false,
|
|
431
|
+
via: "link",
|
|
432
|
+
names,
|
|
433
|
+
saved: completed ? names : [],
|
|
434
|
+
url: firstBlob?.url,
|
|
435
|
+
message: completed ? lastBlob.message : failureMessage,
|
|
436
|
+
port: lastBlob.port,
|
|
437
|
+
out,
|
|
438
|
+
error: completed ? undefined : exitCode !== 0 ? "cli-failed" : "not-provisioned",
|
|
439
|
+
}, lines.join("\n"), !completed);
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
443
|
+
return makeResult({
|
|
444
|
+
success: false,
|
|
445
|
+
aborted: false,
|
|
446
|
+
via: "link",
|
|
447
|
+
names: params.names,
|
|
448
|
+
saved: [],
|
|
449
|
+
error: "spawn-failed",
|
|
450
|
+
message,
|
|
451
|
+
}, `Failed to start pinchpass: ${message}${stderr ? `\n${stderr}` : ""}`, true);
|
|
408
452
|
}
|
|
409
453
|
finally {
|
|
410
454
|
if (signal)
|
|
411
455
|
signal.removeEventListener("abort", onAbort);
|
|
456
|
+
if (ctx.mode === "tui")
|
|
457
|
+
ctx.ui.setWidget("pinchpass-link", undefined);
|
|
412
458
|
}
|
|
413
459
|
},
|
|
414
|
-
/* Pretty TUI rendering: call line, result card. */
|
|
460
|
+
/* Pretty TUI rendering: call line, result card (default tool shell). */
|
|
415
461
|
renderCall(args, theme) {
|
|
416
462
|
const names = (args.names ?? []).join(", ");
|
|
417
463
|
const via = args.via ?? "auto";
|
|
@@ -425,38 +471,46 @@ function registerPiTool(pi) {
|
|
|
425
471
|
return new Text(text, 0, 0);
|
|
426
472
|
},
|
|
427
473
|
renderResult(result, options, theme) {
|
|
474
|
+
const details = (result.details ?? {});
|
|
428
475
|
if (options.isPartial) {
|
|
429
|
-
|
|
476
|
+
if (details.url) {
|
|
477
|
+
const pending = (details.names ?? []).join(", ");
|
|
478
|
+
let text = theme.fg("accent", "🔗 Open this one-time link in your browser");
|
|
479
|
+
if (pending)
|
|
480
|
+
text += theme.fg("dim", ` — submit ${pending}`);
|
|
481
|
+
text += `\n${theme.fg("toolTitle", theme.bold(details.url))}`;
|
|
482
|
+
text += `\n${theme.fg("warning", "⏳ Waiting for submission…")}${theme.fg("muted", " esc to cancel")}`;
|
|
483
|
+
return new Text(text, 0, 0);
|
|
484
|
+
}
|
|
485
|
+
return new Text(theme.fg("warning", "⏳ Starting pinchpass…"), 0, 0);
|
|
430
486
|
}
|
|
431
|
-
const details = (result.details ?? {});
|
|
432
487
|
const success = details.success === true;
|
|
433
488
|
const aborted = details.aborted === true;
|
|
434
489
|
const names = (details.names ?? []).join(", ");
|
|
435
|
-
const bg = success ? "toolSuccessBg" : aborted ? "toolPendingBg" : "toolErrorBg";
|
|
436
|
-
const box = new Box(1, 1, (t) => theme.bg(bg, t));
|
|
437
490
|
const statusColor = success ? "success" : aborted ? "warning" : "error";
|
|
438
491
|
const head = success
|
|
439
492
|
? "✅ Secrets collected"
|
|
440
493
|
: aborted
|
|
441
494
|
? "✋ Cancelled"
|
|
442
495
|
: `❌ ${details.message ?? "Failed"}`;
|
|
443
|
-
|
|
496
|
+
let text = theme.fg("toolTitle", theme.bold(head));
|
|
444
497
|
if (names)
|
|
445
|
-
|
|
498
|
+
text += `\n${theme.fg(statusColor, ` ${names}`)}`;
|
|
446
499
|
if (options.expanded) {
|
|
447
|
-
|
|
500
|
+
if (details.via)
|
|
501
|
+
text += `\n${theme.fg("dim", ` via: ${details.via}`)}`;
|
|
448
502
|
if (details.url)
|
|
449
|
-
|
|
503
|
+
text += `\n${theme.fg("dim", ` Link: ${details.url}`)}`;
|
|
450
504
|
if (details.out)
|
|
451
|
-
|
|
505
|
+
text += `\n${theme.fg("dim", ` Saved to: ${details.out}`)}`;
|
|
452
506
|
if (details.message)
|
|
453
|
-
|
|
454
|
-
|
|
507
|
+
text += `\n${theme.fg("muted", ` ${details.message}`)}`;
|
|
508
|
+
text += "\n";
|
|
455
509
|
for (const line of PINCHPASS_ART.split("\n")) {
|
|
456
|
-
|
|
510
|
+
text += `\n${theme.fg("muted", ` ${line}`)}`;
|
|
457
511
|
}
|
|
458
512
|
}
|
|
459
|
-
return
|
|
513
|
+
return new Text(text, 0, 0);
|
|
460
514
|
},
|
|
461
515
|
});
|
|
462
516
|
}
|
|
@@ -483,16 +537,10 @@ async function registerOpenClawTool(api) {
|
|
|
483
537
|
.describe("Create a public URL via bore.pub tunnel (default: false)"),
|
|
484
538
|
note: tool.schema.string().optional().describe("Description shown on the form to help the user"),
|
|
485
539
|
ttl: tool.schema.number().optional().describe("Minutes until the link expires (default: 30)"),
|
|
540
|
+
out: tool.schema.string().optional().describe("Output .env file path (default: .env)"),
|
|
486
541
|
},
|
|
487
542
|
async execute(args) {
|
|
488
|
-
const
|
|
489
|
-
const pieces = [binary, "request", ...args.names, "-json"];
|
|
490
|
-
if (args.tunnel)
|
|
491
|
-
pieces.push("-tunnel");
|
|
492
|
-
if (args.note)
|
|
493
|
-
pieces.push("-note", args.note);
|
|
494
|
-
if (args.ttl)
|
|
495
|
-
pieces.push("-ttl", String(args.ttl));
|
|
543
|
+
const pieces = [findBinary() ?? "pinchpass", ...buildArgs(args)];
|
|
496
544
|
try {
|
|
497
545
|
const result = await api.$.raw(pieces);
|
|
498
546
|
return result?.stdout ||
|