@pinchpass/cli 1.0.6 → 1.1.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/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @pinchpass/cli
2
+
3
+ One-time **E2E-encrypted** secret request links for AI agents.
4
+
5
+ Generate a temporary HTTP server with a single-use encrypted form for
6
+ collecting sensitive values (API keys, tokens, passwords) — locally or over
7
+ the internet via a built-in bore.pub tunnel. **The relay never sees the
8
+ secret**: encryption happens in the browser, and the decryption key lives only
9
+ in the URL fragment.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm i -g @pinchpass/cli
15
+ ```
16
+
17
+ The install downloads the prebuilt `pinchpass` binary for your platform to
18
+ `~/.local/bin` (or finds an existing install).
19
+
20
+ ## Pi (pi.dev)
21
+
22
+ This package is a native [Pi](https://pi.dev) package:
23
+
24
+ ```sh
25
+ pi install npm:@pinchpass/cli
26
+ ```
27
+
28
+ That registers:
29
+
30
+ - **`request_secret` tool** — two collection modes:
31
+ - `via: "modal"` (default in the TUI): a Pi dialog collects each secret
32
+ directly into `.env` — the value never reaches the LLM, never enters the
33
+ transcript, and no server is started. Cancelable with `Esc`.
34
+ - `via: "link"`: spawns the CLI, streams the one-time E2E-encrypted link to
35
+ the user immediately, blocks until they submit or the TTL expires.
36
+ - `via` defaults to `"auto"`: modal in the TUI, link everywhere else.
37
+ - **`pinchpass` skill** — full usage guidance for the model.
38
+
39
+ The binary is resolved from `PATH`, `~/.local/bin`, `~/.openclaw/bin`, and
40
+ `~/.pi/bin` — no PATH configuration required (not needed for the modal mode).
41
+
42
+ ## OpenClaw
43
+
44
+ Installed as a plugin, it registers the same `request_secret` tool. Note that
45
+ the OpenClaw path executes the CLI in the foreground: keep the exec timeout
46
+ longer than the TTL, or the link dies with the exec session.
47
+
48
+ ## CLI
49
+
50
+ ```sh
51
+ pinchpass request <secret-name>... [flags]
52
+ ```
53
+
54
+ | Flag | Default | Description |
55
+ |------------|-----------|-----------------------------------------|
56
+ | `-tunnel` | false | Open a bore.pub tunnel for a public URL |
57
+ | `-note` | — | Description shown on the form |
58
+ | `-out` | `.env` | Output .env file path |
59
+ | `-ttl` | 30 | Minutes until the link expires |
60
+ | `-port` | random | Local port to bind |
61
+ | `-json` | false | Machine-readable JSON output |
62
+
63
+ ## License
64
+
65
+ MIT
package/dist/index.js CHANGED
@@ -1,5 +1,470 @@
1
- import { tool } from "@opencode-ai/plugin/tool";
2
- export default async ({ $ }) => {
1
+ import { Type } from "typebox";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Box, Text } from "@earendil-works/pi-tui";
4
+ 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";
8
+ /** Small figlet "small" font rendering of PINCHPASS. */
9
+ const PINCHPASS_ART = " ___ ___ _ _ ___ _ _ ___ _ ___ ___ \n" +
10
+ " | _ \\_ _| \\| |/ __| || | _ \\/_\\ / __/ __|\n" +
11
+ " | _/| || .` | (__| __ | _/ _ \\\\__ \\__ \\\n" +
12
+ " |_| |___|_|\\_|\\___|_||_|_|/_/ \\_\\___/___/";
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) : "" };
82
+ }
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 });
115
+ }
116
+ /**
117
+ * Branded masked secret dialog (Pi TUI overlay). The value is rendered as
118
+ * bullets, never echoed, and submitted straight to the caller.
119
+ */
120
+ async function promptSecretTUI(ctx, name, note, signal) {
121
+ return ctx.ui.custom((tui, theme, _kb, done) => {
122
+ let value = "";
123
+ let alive = true;
124
+ const finish = (v) => {
125
+ if (!alive)
126
+ return;
127
+ alive = false;
128
+ if (signal)
129
+ signal.removeEventListener("abort", onAbort);
130
+ done(v);
131
+ };
132
+ const onAbort = () => finish(undefined);
133
+ if (signal) {
134
+ if (signal.aborted) {
135
+ onAbort();
136
+ }
137
+ else {
138
+ signal.addEventListener("abort", onAbort, { once: true });
139
+ }
140
+ }
141
+ const comp = {
142
+ focused: true,
143
+ handleInput(data) {
144
+ if (!alive)
145
+ return;
146
+ // Ignore escape sequences (arrows etc.) as a whole chunk.
147
+ if (data.startsWith("\u001b["))
148
+ return;
149
+ if (data === "\u001b") {
150
+ finish(undefined);
151
+ return;
152
+ }
153
+ 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") {
166
+ value += ch;
167
+ }
168
+ }
169
+ tui.requestRender(true);
170
+ },
171
+ invalidate() { },
172
+ render(width) {
173
+ 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));
176
+ const lines = [];
177
+ 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}`)));
182
+ 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) + "┘"));
195
+ lines.push("");
196
+ lines.push(" ".repeat(pad) +
197
+ theme.fg("dim", "enter submit") +
198
+ theme.fg("muted", " esc cancel"));
199
+ return lines;
200
+ },
201
+ };
202
+ return comp;
203
+ }, {
204
+ overlay: true,
205
+ overlayOptions: { width: "60%", minWidth: 54, maxHeight: "65%" },
206
+ });
207
+ }
208
+ /**
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.
212
+ */
213
+ async function collectViaModal(ctx, params, signal) {
214
+ const outPath = resolve(ctx.cwd, params.out ?? ".env");
215
+ const saved = [];
216
+ 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
+ });
222
+ 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
+ };
232
+ }
233
+ const trimmed = value.trim();
234
+ 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
+ };
244
+ }
245
+ writeEnvKey(outPath, name, trimmed);
246
+ saved.push(name);
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;
273
+ }
274
+ /* ------------------------------------------------------------------ */
275
+ /* Pi native tool */
276
+ /* ------------------------------------------------------------------ */
277
+ function registerPiTool(pi) {
278
+ pi.registerTool({
279
+ name: "request_secret",
280
+ label: "Request Secret",
281
+ description: "Collect sensitive values (API keys, tokens, passwords, connection strings) from the user. " +
282
+ "With via='modal' (default in the Pi TUI) a dialog collects each value directly into the .env file — the value never reaches the LLM. " +
283
+ "With via='link' it starts a local pinchpass server and streams a one-time E2E-encrypted link to the user immediately, blocking until they submit or the TTL expires. " +
284
+ "On success the secrets are saved to the .env file and this tool returns the result.",
285
+ promptSnippet: "Collect secrets from the user through an encrypted one-time link or a Pi modal",
286
+ promptGuidelines: [
287
+ "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
+ "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.",
290
+ "Use request_secret with via: 'link' to force the encrypted browser link, or via: 'modal' to force the Pi dialog.",
291
+ ],
292
+ parameters: Type.Object({
293
+ names: Type.Array(Type.String({ description: "Secret name(s) to collect, e.g. ['GEMINI_API_KEY']" }), {
294
+ minItems: 1,
295
+ description: "Secret name(s) to collect",
296
+ }),
297
+ tunnel: Type.Optional(Type.Boolean({ description: "Create a public URL via bore.pub tunnel (default: false)" })),
298
+ note: Type.Optional(Type.String({ description: "Description shown on the form to help the user" })),
299
+ ttl: Type.Optional(Type.Number({ description: "Minutes until the link expires (default: 30)" })),
300
+ out: Type.Optional(Type.String({ description: "Output .env file path (default: .env in the working directory)" })),
301
+ via: Type.Optional(StringEnum(["auto", "modal", "link"], {
302
+ 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
+ })),
304
+ }),
305
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
306
+ const via = params.via ?? "auto";
307
+ const useModal = via === "modal" || (via === "auto" && ctx.mode === "tui");
308
+ 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
+ return collectViaModal(ctx, params, signal);
321
+ }
322
+ const binary = findBinary();
323
+ if (!binary) {
324
+ throw new Error(BINARY_MISSING);
325
+ }
326
+ const child = spawn(binary, buildArgs(params), {
327
+ cwd: ctx.cwd,
328
+ stdio: ["ignore", "pipe", "pipe"],
329
+ });
330
+ let buffer = "";
331
+ let stderr = "";
332
+ const blobs = [];
333
+ const onAbort = () => {
334
+ try {
335
+ child.kill("SIGTERM");
336
+ }
337
+ catch {
338
+ // already gone
339
+ }
340
+ };
341
+ if (signal)
342
+ signal.addEventListener("abort", onAbort, { once: true });
343
+ try {
344
+ const exitCode = await new Promise((resolve, reject) => {
345
+ child.stdout.on("data", (chunk) => {
346
+ buffer += chunk.toString("utf8");
347
+ const { blobs: found, rest } = takeJsonBlobs(buffer);
348
+ buffer = rest;
349
+ for (const blob of found) {
350
+ blobs.push(blob);
351
+ const b = blob;
352
+ if (b.url) {
353
+ const ttlNote = b.message ? ` (${b.message})` : "";
354
+ onUpdate?.({
355
+ content: [
356
+ {
357
+ type: "text",
358
+ 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
+ },
360
+ ],
361
+ details: { url: b.url, port: b.port, names: b.names ?? [] },
362
+ });
363
+ }
364
+ }
365
+ });
366
+ child.stderr.on("data", (chunk) => {
367
+ stderr += chunk.toString("utf8");
368
+ });
369
+ child.on("error", (err) => reject(err));
370
+ child.on("close", (code) => resolve(code));
371
+ });
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
+ }
383
+ const firstBlob = blobs[0];
384
+ const lastBlob = blobs[blobs.length - 1];
385
+ if (!lastBlob) {
386
+ throw new Error(`pinchpass exited without JSON output (code ${exitCode}).${stderr ? `\n${stderr}` : ""}`);
387
+ }
388
+ const success = lastBlob.success === true;
389
+ const lines = success
390
+ ? [`✅ Secrets collected: ${(lastBlob.names ?? params.names).join(", ")}`]
391
+ : [`❌ ${lastBlob.message ?? "Secret request failed."}`];
392
+ if (firstBlob?.url)
393
+ lines.push(`Link: ${firstBlob.url}`);
394
+ if (success)
395
+ 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
+ };
408
+ }
409
+ finally {
410
+ if (signal)
411
+ signal.removeEventListener("abort", onAbort);
412
+ }
413
+ },
414
+ /* Pretty TUI rendering: call line, result card. */
415
+ renderCall(args, theme) {
416
+ const names = (args.names ?? []).join(", ");
417
+ const via = args.via ?? "auto";
418
+ let text = theme.fg("accent", "🔐 ") +
419
+ theme.fg("toolTitle", theme.bold("pinchpass")) +
420
+ theme.fg("dim", " · ") +
421
+ theme.fg("accent", names);
422
+ if (args.tunnel)
423
+ text += theme.fg("dim", " · tunnel");
424
+ text += theme.fg("muted", ` · ${via}`);
425
+ return new Text(text, 0, 0);
426
+ },
427
+ renderResult(result, options, theme) {
428
+ if (options.isPartial) {
429
+ return new Text(theme.fg("warning", "⏳ Waiting for the user to submit…"), 0, 0);
430
+ }
431
+ const details = (result.details ?? {});
432
+ const success = details.success === true;
433
+ const aborted = details.aborted === true;
434
+ 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
+ const statusColor = success ? "success" : aborted ? "warning" : "error";
438
+ const head = success
439
+ ? "✅ Secrets collected"
440
+ : aborted
441
+ ? "✋ Cancelled"
442
+ : `❌ ${details.message ?? "Failed"}`;
443
+ box.addChild(new Text(theme.fg("toolTitle", theme.bold(head))));
444
+ if (names)
445
+ box.addChild(new Text(theme.fg(statusColor, ` ${names}`)));
446
+ if (options.expanded) {
447
+ box.addChild(new Text(""));
448
+ if (details.url)
449
+ box.addChild(new Text(theme.fg("dim", ` Link: ${details.url}`)));
450
+ if (details.out)
451
+ box.addChild(new Text(theme.fg("dim", ` Saved to: ${details.out}`)));
452
+ if (details.message)
453
+ box.addChild(new Text(theme.fg("muted", ` ${details.message}`)));
454
+ box.addChild(new Text(""));
455
+ for (const line of PINCHPASS_ART.split("\n")) {
456
+ box.addChild(new Text(theme.fg("muted", ` ${line}`)));
457
+ }
458
+ }
459
+ return box;
460
+ },
461
+ });
462
+ }
463
+ /* ------------------------------------------------------------------ */
464
+ /* OpenClaw plugin (backward compatible) */
465
+ /* ------------------------------------------------------------------ */
466
+ async function registerOpenClawTool(api) {
467
+ const { tool } = await import("@opencode-ai/plugin/tool");
3
468
  return {
4
469
  tool: {
5
470
  request_secret: tool({
@@ -16,17 +481,12 @@ export default async ({ $ }) => {
16
481
  .boolean()
17
482
  .optional()
18
483
  .describe("Create a public URL via bore.pub tunnel (default: false)"),
19
- note: tool.schema
20
- .string()
21
- .optional()
22
- .describe("Description shown on the form to help the user"),
23
- ttl: tool.schema
24
- .number()
25
- .optional()
26
- .describe("Minutes until the link expires (default: 30)"),
484
+ note: tool.schema.string().optional().describe("Description shown on the form to help the user"),
485
+ ttl: tool.schema.number().optional().describe("Minutes until the link expires (default: 30)"),
27
486
  },
28
487
  async execute(args) {
29
- const pieces = ["pinchpass", "request", ...args.names, "-json"];
488
+ const binary = findBinary() ?? "pinchpass";
489
+ const pieces = [binary, "request", ...args.names, "-json"];
30
490
  if (args.tunnel)
31
491
  pieces.push("-tunnel");
32
492
  if (args.note)
@@ -34,18 +494,27 @@ export default async ({ $ }) => {
34
494
  if (args.ttl)
35
495
  pieces.push("-ttl", String(args.ttl));
36
496
  try {
37
- const result = await $.raw(pieces);
38
- return result.stdout || result.text || String(result);
497
+ const result = await api.$.raw(pieces);
498
+ return result?.stdout ||
499
+ result?.text ||
500
+ String(result);
39
501
  }
40
502
  catch (err) {
41
503
  const msg = err instanceof Error ? err.message : String(err);
42
- const stderr = err && typeof err === "object" && "stderr" in err
43
- ? err.stderr
44
- : "";
504
+ const stderr = err && typeof err === "object" && "stderr" in err ? err.stderr : "";
45
505
  return `Failed: ${msg}${stderr ? "\n" + stderr : ""}`;
46
506
  }
47
507
  },
48
508
  }),
49
509
  },
50
510
  };
51
- };
511
+ }
512
+ export default async function (api) {
513
+ const pi = api;
514
+ if (pi && typeof pi.registerTool === "function") {
515
+ registerPiTool(pi);
516
+ return;
517
+ }
518
+ // OpenClaw plugin contract: default export receives `{ $ }` and returns `{ tool }`.
519
+ return registerOpenClawTool((api ?? {}));
520
+ }
package/package.json CHANGED
@@ -1,27 +1,61 @@
1
1
  {
2
2
  "name": "@pinchpass/cli",
3
- "version": "1.0.6",
4
- "description": "One-time E2E-encrypted secret request links for OpenClaw agents",
3
+ "version": "1.1.1",
4
+ "description": "One-time E2E-encrypted secret request links — native for Pi and OpenClaw agents",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/rubybear-lgtm/PinchPass.git"
8
+ },
9
+ "keywords": [
10
+ "pi-package",
11
+ "pinchpass",
12
+ "secrets",
13
+ "api-key",
14
+ "e2e-encryption",
15
+ "openclaw"
16
+ ],
5
17
  "type": "module",
6
18
  "main": "./dist/index.js",
7
19
  "files": [
8
20
  "dist",
9
- "SKILL.md",
21
+ "skills",
10
22
  "openclaw.plugin.json",
11
- "scripts"
23
+ "scripts",
24
+ "README.md"
12
25
  ],
26
+ "pi": {
27
+ "extensions": [
28
+ "./dist/index.js"
29
+ ],
30
+ "skills": [
31
+ "./skills"
32
+ ],
33
+ "image": "https://raw.githubusercontent.com/rubybear-lgtm/PinchPass/master/pinchpass.png"
34
+ },
13
35
  "openclaw": {
14
- "extensions": ["./dist/index.js"]
36
+ "extensions": [
37
+ "./dist/index.js"
38
+ ]
15
39
  },
16
40
  "scripts": {
17
- "build": "tsc",
41
+ "build": "tsc && node scripts/copy-skill.js",
18
42
  "postinstall": "node scripts/install-binary.js",
19
43
  "prepublishOnly": "npm run build"
20
44
  },
21
45
  "dependencies": {
22
46
  "@opencode-ai/plugin": "^1.16.2"
23
47
  },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-ai": "*",
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "@earendil-works/pi-tui": "*",
52
+ "typebox": "*"
53
+ },
24
54
  "devDependencies": {
55
+ "@earendil-works/pi-ai": "*",
56
+ "@earendil-works/pi-coding-agent": "^0.83.0",
57
+ "@earendil-works/pi-tui": "*",
58
+ "typebox": "^1.3.7",
25
59
  "typescript": "^5.9.0"
26
60
  },
27
61
  "publishConfig": {
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copies the canonical universal skill from the repo root
4
+ * (skills/pinchpass/SKILL.md) into the package (skills/pinchpass/SKILL.md)
5
+ * so the Pi package ships the exact same skill as skills.sh.
6
+ */
7
+ import { copyFileSync, mkdirSync } from "fs";
8
+ import { dirname, join } from "path";
9
+ import { fileURLToPath } from "url";
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const packageRoot = join(here, "..");
13
+ const repoRoot = join(packageRoot, "..", "..");
14
+
15
+ const src = join(repoRoot, "skills", "pinchpass", "SKILL.md");
16
+ const dest = join(packageRoot, "skills", "pinchpass", "SKILL.md");
17
+
18
+ mkdirSync(dirname(dest), { recursive: true });
19
+ copyFileSync(src, dest);
20
+ console.log(`Copied skill → ${dest}`);
@@ -1,34 +1,86 @@
1
1
  #!/usr/bin/env node
2
-
2
+ /**
3
+ * Downloads the prebuilt pinchpass binary for the current platform.
4
+ *
5
+ * Install locations (first hit wins):
6
+ * 1. If `pinchpass` is already on PATH, nothing to do.
7
+ * 2. If it already exists in a known install dir, nothing to do.
8
+ * 3. Install to ~/.local/bin (universal user bin — the PinchPass Pi
9
+ * extension and OpenClaw plugin both find it there without PATH changes).
10
+ *
11
+ * The Pi extension resolves the binary from PATH, then from
12
+ * ~/.local/bin, ~/.openclaw/bin, and ~/.pi/bin — so PATH is never required.
13
+ */
3
14
  import { execSync } from "child_process";
4
- import { existsSync, mkdirSync, writeFileSync, chmodSync } from "fs";
5
- import { homedir, platform } from "os";
15
+ import { chmodSync, existsSync, mkdirSync, statSync } from "fs";
16
+ import { homedir } from "os";
17
+ import { join } from "path";
6
18
 
7
19
  const VERSION = "latest";
8
20
  const REPO = "rubybear-lgtm/PinchPass";
9
21
 
22
+ /** Real pinchpass binaries are multi-MB; "installed" stubs are tiny (e.g. a failed curl). */
23
+ const MIN_VALID_BYTES = 100 * 1024;
24
+
25
+ function isValidBinary(path) {
26
+ try {
27
+ return statSync(path).size >= MIN_VALID_BYTES;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
10
33
  function detectBinary() {
11
34
  const arch = process.arch === "arm64" ? "arm64" : "amd64";
12
- const osMap = {
13
- darwin: "darwin",
14
- linux: "linux",
15
- win32: "windows",
16
- };
35
+ const osMap = { darwin: "darwin", linux: "linux", win32: "windows" };
17
36
  const plat = osMap[process.platform];
18
37
  if (!plat) throw new Error(`Unsupported platform: ${process.platform}`);
19
38
  return { name: `pinchpass-${plat}-${arch}`, plat, arch };
20
39
  }
21
40
 
41
+ function onPath(name) {
42
+ try {
43
+ const found = execSync(
44
+ process.platform === "win32" ? `where ${name}` : `which ${name}`,
45
+ { stdio: ["ignore", "pipe", "ignore"], shell: true },
46
+ )
47
+ .toString()
48
+ .trim()
49
+ .split(/\r?\n/)[0];
50
+ return found || null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
22
56
  async function install() {
23
57
  const { name } = detectBinary();
24
- const installDir = `${homedir()}/.openclaw/bin`;
25
- const dest = `${installDir}/pinchpass`;
58
+ const exe = process.platform === "win32" ? ".exe" : "";
59
+ const knownDirs = [
60
+ join(homedir(), ".local", "bin"),
61
+ join(homedir(), ".openclaw", "bin"),
62
+ join(homedir(), ".pi", "bin"),
63
+ ];
26
64
 
27
- if (existsSync(dest)) {
28
- console.log(`pinchpass already installed at ${dest}`);
65
+ // 1. Already on PATH?
66
+ const onPathLocation = onPath(`pinchpass${exe}`);
67
+ if (onPathLocation && isValidBinary(onPathLocation)) {
68
+ console.log(`pinchpass already available on PATH at ${onPathLocation}`);
29
69
  return;
30
70
  }
31
71
 
72
+ // 2. Already installed in a known dir? (skip broken stubs from failed downloads)
73
+ for (const dir of knownDirs) {
74
+ const candidate = join(dir, `pinchpass${exe}`);
75
+ if (existsSync(candidate) && isValidBinary(candidate)) {
76
+ console.log(`pinchpass already installed at ${candidate}`);
77
+ return;
78
+ }
79
+ }
80
+
81
+ // 3. Install to ~/.local/bin (universal user bin).
82
+ const installDir = join(homedir(), ".local", "bin");
83
+ const dest = join(installDir, `pinchpass${exe}`);
32
84
  const url =
33
85
  VERSION === "latest"
34
86
  ? `https://github.com/${REPO}/releases/latest/download/${name}`
@@ -37,22 +89,19 @@ async function install() {
37
89
  console.log(`Downloading pinchpass from ${url}...`);
38
90
 
39
91
  mkdirSync(installDir, { recursive: true });
40
-
41
- if (process.platform === "win32") {
42
- execSync(`curl -sL "${url}" -o "${dest}.exe"`, { stdio: "inherit" });
43
- } else {
44
- execSync(`curl -sL "${url}" -o "${dest}"`, { stdio: "inherit" });
45
- chmodSync(dest, 0o755);
46
- }
92
+ execSync(`curl -sL "${url}" -o "${dest}"`, { stdio: "inherit" });
93
+ if (process.platform !== "win32") chmodSync(dest, 0o755);
47
94
 
48
95
  console.log(`Installed pinchpass to ${dest}`);
49
- console.log(`Add ${installDir} to your PATH if not already present.`);
96
+ const onPathNow = onPath(`pinchpass${exe}`);
97
+ if (!onPathNow) {
98
+ console.log(`Add ${installDir} to your PATH if you want to use the CLI directly.`);
99
+ console.log("(The PinchPass Pi extension and OpenClaw plugin find the binary without PATH changes.)");
100
+ }
50
101
  }
51
102
 
52
103
  install().catch((err) => {
53
104
  console.error(`Failed to install pinchpass binary: ${err.message}`);
54
- console.log(
55
- `To build from source: cd pinchpass-plugin && go build -o pinchpass .`
56
- );
105
+ console.log(`To build from source: cd pinchpass && go build -o pinchpass .`);
57
106
  process.exit(1);
58
107
  });
@@ -0,0 +1,176 @@
1
+ ---
2
+ name: pinchpass
3
+ description: "Collect sensitive values (API keys, tokens, passwords, credentials) from the user via a one-time E2E-encrypted link. Use when you need a secret the user hasn't provided, when populating a .env file with credentials, or when the user says they need to give you a key/token/password. "
4
+ ---
5
+
6
+ Securely collect sensitive values from users with end-to-end encryption. The
7
+ relay server never sees the plaintext — encryption happens in the browser, and
8
+ the decryption key lives only in the URL fragment (never sent to the server).
9
+
10
+ ## When to use
11
+
12
+ - An agent needs a value it does not have (API key, token, password, etc.)
13
+ - The user says "I need to give you a secret" or "where do I put my key?"
14
+ - A `.env` file needs to be populated with credentials
15
+
16
+ Do **not** use for non-sensitive plaintext — there is no reason to go through
17
+ E2E encryption for a public value. Just ask the user to paste it directly.
18
+
19
+ ## Install
20
+
21
+ Check that `pinchpass` is available:
22
+
23
+ ```bash
24
+ which pinchpass
25
+ ```
26
+
27
+ If missing, install it (pick one):
28
+
29
+ ```bash
30
+ # Pi (native package — registers the request_secret tool and this skill)
31
+ pi install npm:@pinchpass/cli
32
+ ```
33
+
34
+ ```bash
35
+ # Any agent (npm — downloads the prebuilt binary)
36
+ npm i -g @pinchpass/cli
37
+ ```
38
+
39
+ ```bash
40
+ # From source (requires Go 1.25+)
41
+ go install github.com/rubybear-lgtm/pinchpass@latest
42
+ ```
43
+
44
+ ```bash
45
+ # Prebuilt binary — pick the right platform/arch from:
46
+ # https://github.com/rubybear-lgtm/PinchPass/releases
47
+ curl -sL https://github.com/rubybear-lgtm/PinchPass/releases/latest/download/pinchpass-darwin-arm64 -o /usr/local/bin/pinchpass
48
+ chmod +x /usr/local/bin/pinchpass
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ Always use `-json` mode so you can parse the output programmatically.
54
+
55
+ ```bash
56
+ pinchpass request <secret-name>... [flags]
57
+ ```
58
+
59
+ Bare `pinchpass <name>` also works (auto-detects missing subcommand).
60
+
61
+ ### Collect a single secret
62
+
63
+ ```bash
64
+ pinchpass request SECRET_NAME -json
65
+ ```
66
+
67
+ ### Collect multiple secrets at once
68
+
69
+ ```bash
70
+ pinchpass request KEY_A KEY_B KEY_C -json
71
+ ```
72
+
73
+ ### Create a public link via tunnel
74
+
75
+ ```bash
76
+ pinchpass request SECRET_NAME -tunnel -json
77
+ ```
78
+
79
+ Use `-tunnel` when the user is not on the same machine (the default binds to
80
+ `127.0.0.1` only).
81
+
82
+ ### All flags
83
+
84
+ | Flag | Default | Description |
85
+ |----------------|---------------|------------------------------------------|
86
+ | `-tunnel` | false | Open a bore.pub tunnel for a public URL |
87
+ | `-note` | — | Description shown on the form |
88
+ | `-out` | `.env` | Output .env file path |
89
+ | `-ttl` | 30 | Minutes until the link expires |
90
+ | `-port` | random | Local port to bind |
91
+ | `-listen-addr` | `127.0.0.1` | Address to listen on |
92
+ | `-json` | false | Machine-readable JSON output |
93
+
94
+ ## Pi-native tool
95
+
96
+ On Pi, the `@pinchpass/cli` package registers a **`request_secret`** tool with
97
+ two collection modes (`via` param):
98
+
99
+ - **`modal`** (default in the interactive TUI): a Pi dialog collects each
100
+ secret directly into the `.env` file. The value goes straight from the
101
+ user's keyboard to disk — never to the LLM, never into the transcript, no
102
+ server, no network. Cancelable with `Esc`.
103
+ - **`link`**: spawns the CLI, streams the one-time E2E-encrypted browser link
104
+ to the user immediately, blocks until submit/TTL. Use when the user is not
105
+ in the same Pi session (remote, tunneled, or RPC clients without a dialog
106
+ handler).
107
+
108
+ `via` defaults to `auto`: modal in the TUI, link elsewhere. Prefer the tool on
109
+ Pi; use the CLI workflow below on other agents.
110
+
111
+ ## Workflow
112
+
113
+ 1. Run `pinchpass request <name>... -json` in the background.
114
+ 2. Parse the first JSON blob from stdout — it contains the `url` field.
115
+ 3. Present the URL to the user and tell them to open it in a browser.
116
+ 4. Wait for the process to exit (it blocks until the user submits or TTL expires).
117
+ 5. Parse the second JSON blob — `success: true` means the value was saved to `.env`.
118
+
119
+ ### JSON output
120
+
121
+ Two JSON objects are printed to stdout, one before waiting and one after:
122
+
123
+ **Before waiting:**
124
+ ```json
125
+ {"success": true, "names": ["SECRET_NAME"], "url": "http://...", "port": 12345, "message": "..."}
126
+ ```
127
+
128
+ **After completion:**
129
+ ```json
130
+ {"success": true, "names": ["SECRET_NAME"], "port": 12345, "message": "Secrets provisioned successfully."}
131
+ ```
132
+
133
+ If `success` is false, the request timed out — no value was saved.
134
+
135
+ ### Workflow example
136
+
137
+ ```bash
138
+ # Collect an API key via public tunnel
139
+ pinchpass request API_KEY -tunnel -json
140
+ # → Prints link object with url field
141
+ # → Agent gives url to user
142
+ # → Agent waits for result
143
+ # → On completion, value in .env
144
+ ```
145
+
146
+ ## Important behavior
147
+
148
+ - **Blocking + exec timeouts**: `pinchpass` blocks until the user submits or
149
+ TTL expires. If your agent's shell/exec tool has a command timeout (e.g.
150
+ OpenClaw's `exec` tool defaults to 1800s), it **will kill pinchpass** when
151
+ the timeout fires — and the bore tunnel dies with it, making the link return
152
+ "Connection refused." You MUST disable or raise the exec timeout so it
153
+ exceeds the TTL. Agent-specific guidance:
154
+ - **OpenClaw**: pass `timeout: 0` in the exec tool call (disables the exec
155
+ process timeout), and use `background: true` (or `yieldMs`) so you can
156
+ present the URL from the first JSON blob while pinchpass waits. Poll the
157
+ process or watch for the `.env` file to appear.
158
+ - **Claude Code / opencode**: use `run_in_background` / background bash. The
159
+ background session is not subject to the foreground command timeout.
160
+ - **Other agents**: if the agent has no background/timeout-free mode, run
161
+ pinchpass detached: `setsid nohup pinchpass request NAME -tunnel -json
162
+ > /tmp/pinchpass.json 2>&1 &` then read `/tmp/pinchpass.json` for the URL.
163
+ The process survives the agent's exec session. Check for the `.env` file
164
+ or poll `ps` to detect completion.
165
+ - **One-time use**: after the first submission, the token is consumed. A second
166
+ POST returns 404. If it fails, generate a new link.
167
+ - **TTL expiry**: the server shuts down after the TTL. Exit code is 1. Generate
168
+ a new link with a longer `-ttl` if needed. Keep the TTL shorter than the exec
169
+ timeout so pinchpass exits on its own before the agent can kill it.
170
+ - **Shell escaping**: values in `.env` are shell-escaped (`\`, `"`, `$`, `` ` ``).
171
+ Existing keys are overwritten in place.
172
+ - **No tunnel**: without `-tunnel`, the link is only reachable on
173
+ `127.0.0.1:<port>`. Use for local-only workflows or when the user is on the
174
+ same machine.
175
+ - **bore.pub unreachable**: tunnel startup fails if bore.pub is down. Fall back
176
+ to local mode or retry.
package/SKILL.md DELETED
@@ -1,81 +0,0 @@
1
- ---
2
- name: pinchpass
3
- description: Generate one-time E2E-encrypted secret request links via pinchpass. Use when an agent needs to collect API keys, tokens, passwords, connection strings, or any sensitive value from the user. Trigger on keywords: API key, secret, token, credentials, password, .env.
4
- version: 1.0.0
5
- homepage: https://github.com/rubybear-lgtm/PinchPass
6
- metadata: { "openclaw": { "requires": { "bins": ["pinchpass"] } } }
7
- ---
8
-
9
- # pinchpass
10
-
11
- Securely collect sensitive values from users with end-to-end encryption. The
12
- relay never sees the secret — encryption happens in the browser, the key lives
13
- only in the URL fragment.
14
-
15
- ## When to use
16
-
17
- - An agent needs a value it does not have (API key, token, password, etc.)
18
- - The user says "I need to give you a secret" or "where do I put my key?"
19
- - A `.env` file needs to be populated with credentials
20
-
21
- Do **not** use for non-sensitive plaintext — there is no reason to go through
22
- E2E encryption for a public value.
23
-
24
- ## CLI
25
-
26
- ```bash
27
- pinchpass request <secret-name>... [flags]
28
- ```
29
-
30
- Bare `pinchpass <name>` also works (auto-detects missing subcommand).
31
-
32
- ### Flags
33
-
34
- | Flag | Default | Description |
35
- |---------------|---------------|------------------------------------------|
36
- | `-tunnel` | false | Open a bore.pub tunnel for a public URL |
37
- | `-note` | — | Description shown on the form |
38
- | `-out` | `.env` | Output .env file path |
39
- | `-ttl` | 30 | Minutes until the link expires |
40
- | `-port` | random | Local port to bind |
41
- | `-listen-addr`| `127.0.0.1` | Address to listen on |
42
- | `-json` | false | Machine-readable JSON output |
43
-
44
- ## Workflow
45
-
46
- 1. Run `pinchpass request <name> -json` to get a link
47
- 2. Give the link to the user (the fragment `#k=<hex>` is the decryption key)
48
- 3. User opens the link in a browser, fills in the value, submits
49
- 4. Server waits for the submission or TTL expiry
50
- 5. On success, the value is decrypted and written to `.env`
51
-
52
- ### JSON output mode
53
-
54
- Use `-json` for machine parsing. The JSON is printed twice:
55
- - **Before waiting**: `{ "success": true, "url": "...", "port": N }`
56
- - **After completion**: `{ "success": true/false, "names": [...], "message": "..." }`
57
-
58
- ## Workflow example
59
-
60
- ```bash
61
- # Collect an API key via public tunnel
62
- pinchpass request GEMINI_API_KEY -tunnel -json
63
- # → Prints link object with url field
64
- # → Agent gives url to user
65
- # → Agent waits for result
66
- # → On completion, value in .env
67
- ```
68
-
69
- ## Edge cases
70
-
71
- - **One-time use**: after the first POST, the token is consumed. Retry
72
- generates a 404. If it fails, generate a new link.
73
- - **TTL expiry**: server shuts down after the TTL. No value is saved and exit
74
- code is 1. Generate a new link with a longer `-ttl` if needed.
75
- - **No tunnel**: without `-tunnel`, the link is only reachable on
76
- `127.0.0.1:<port>`. Use for local-only workflows or when the user is on the
77
- same machine.
78
- - **Bore.pub unreachable**: tunnel startup fails if bore.pub is down. Fall
79
- back to local mode or retry.
80
- - **Shell escaping**: values written to `.env` are shell-escaped for `\`,
81
- `"`, `$`, and backtick.