@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/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 { Box, Text } from "@earendil-works/pi-tui";
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 { accessSync, constants, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { homedir } from "node:os";
7
- import { dirname, join, resolve } from "node:path";
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
- /** Extra directories where the postinstall script may have placed the binary. */
14
- const FALLBACK_DIRS = [
15
- join(homedir(), ".local", "bin"),
16
- join(homedir(), ".openclaw", "bin"),
17
- join(homedir(), ".pi", "bin"),
18
- ];
19
- function findBinary() {
20
- const name = process.platform === "win32" ? "pinchpass.exe" : "pinchpass";
21
- const sep = process.platform === "win32" ? ";" : ":";
22
- const dirs = [...(process.env.PATH ?? "").split(sep).filter(Boolean), ...FALLBACK_DIRS];
23
- for (const dir of dirs) {
24
- try {
25
- const candidate = join(dir, name);
26
- accessSync(candidate, constants.X_OK);
27
- return candidate;
28
- }
29
- catch {
30
- // keep looking
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
- /** Shell-escape a value for a double-quoted .env line (mirrors store.go). */
84
- function escapeEnvValue(s) {
85
- return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
86
- }
87
- /** Write/update a single KEY="value" line in a .env file (mirrors store.go). */
88
- function writeEnvKey(path, key, value) {
89
- mkdirSync(dirname(path), { recursive: true });
90
- const line = `${key}="${escapeEnvValue(value)}"`;
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, _kb, done) => {
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
- // Ignore escape sequences (arrows etc.) as a whole chunk.
147
- if (data.startsWith("\u001b["))
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
- if (data === "\u001b") {
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 === "\r" || ch === "\n") {
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
- tui.requestRender(true);
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 boxW = Math.min(width - 6, 46);
175
- const pad = Math.max(0, Math.floor((width - boxW) / 2));
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
- for (const a of art) {
179
- lines.push(" ".repeat(pad) + theme.fg("muted", a));
180
- }
181
- lines.push(" ".repeat(pad) + theme.fg("toolTitle", theme.bold(` 🔐 ${name}`)));
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(" ".repeat(pad) + theme.fg("dim", ` ${note}`));
184
- lines.push("");
185
- const dots = Math.min(value.length, boxW - 8);
186
- const mask = "•".repeat(dots);
187
- const cursor = value.length < boxW - 8 ? "█" : "";
188
- const inner = " " + mask + cursor + " ".repeat(Math.max(0, boxW - 4 - dots - (cursor ? 1 : 0)));
189
- lines.push(" ".repeat(pad) + theme.fg("borderAccent", "┌" + "─".repeat(boxW - 2) + "┐"));
190
- lines.push(" ".repeat(pad) +
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
- lines.push(" ".repeat(pad) +
197
- theme.fg("dim", "enter submit") +
198
- theme.fg("muted", " esc cancel"));
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 UI via modal dialogs. The value goes
210
- * straight from the user's keyboard into the .env file — it is never sent to
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.mode === "tui"
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 ?? "auto";
307
- const useModal = via === "modal" || (via === "auto" && ctx.mode === "tui");
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
- throw new Error(BINARY_MISSING);
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
- if (signal?.aborted) {
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
- throw new Error(`pinchpass exited without JSON output (code ${exitCode}).${stderr ? `\n${stderr}` : ""}`);
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 success = lastBlob.success === true;
389
- const lines = success
390
- ? [`✅ Secrets collected: ${(lastBlob.names ?? params.names).join(", ")}`]
391
- : [`❌ ${lastBlob.message ?? "Secret request failed."}`];
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 (success)
422
+ if (completed)
395
423
  lines.push(`Saved to ${params.out ?? ".env"} in ${ctx.cwd}`);
396
- return {
397
- content: [{ type: "text", text: lines.join("\n") }],
398
- details: {
399
- success,
400
- names: lastBlob.names ?? params.names,
401
- url: firstBlob?.url,
402
- message: lastBlob.message,
403
- port: lastBlob.port,
404
- out: resolve(ctx.cwd, params.out ?? ".env"),
405
- aborted: false,
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
- return new Text(theme.fg("warning", "⏳ Waiting for the user to submit…"), 0, 0);
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
- box.addChild(new Text(theme.fg("toolTitle", theme.bold(head))));
496
+ let text = theme.fg("toolTitle", theme.bold(head));
444
497
  if (names)
445
- box.addChild(new Text(theme.fg(statusColor, ` ${names}`)));
498
+ text += `\n${theme.fg(statusColor, ` ${names}`)}`;
446
499
  if (options.expanded) {
447
- box.addChild(new Text(""));
500
+ if (details.via)
501
+ text += `\n${theme.fg("dim", ` via: ${details.via}`)}`;
448
502
  if (details.url)
449
- box.addChild(new Text(theme.fg("dim", ` Link: ${details.url}`)));
503
+ text += `\n${theme.fg("dim", ` Link: ${details.url}`)}`;
450
504
  if (details.out)
451
- box.addChild(new Text(theme.fg("dim", ` Saved to: ${details.out}`)));
505
+ text += `\n${theme.fg("dim", ` Saved to: ${details.out}`)}`;
452
506
  if (details.message)
453
- box.addChild(new Text(theme.fg("muted", ` ${details.message}`)));
454
- box.addChild(new Text(""));
507
+ text += `\n${theme.fg("muted", ` ${details.message}`)}`;
508
+ text += "\n";
455
509
  for (const line of PINCHPASS_ART.split("\n")) {
456
- box.addChild(new Text(theme.fg("muted", ` ${line}`)));
510
+ text += `\n${theme.fg("muted", ` ${line}`)}`;
457
511
  }
458
512
  }
459
- return box;
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 binary = findBinary() ?? "pinchpass";
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 ||