@pinchpass/cli 1.0.6 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/dist/index.js +486 -17
- package/package.json +32 -5
- package/scripts/copy-skill.js +20 -0
- package/scripts/install-binary.js +72 -23
- package/skills/pinchpass/SKILL.md +176 -0
- package/SKILL.md +0 -81
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 {
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
38
|
-
return 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,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pinchpass/cli",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "One-time E2E-encrypted secret request links for OpenClaw agents",
|
|
3
|
+
"version": "1.1.0",
|
|
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
|
-
"
|
|
21
|
+
"skills",
|
|
10
22
|
"openclaw.plugin.json",
|
|
11
|
-
"scripts"
|
|
23
|
+
"scripts",
|
|
24
|
+
"README.md"
|
|
12
25
|
],
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": ["./dist/index.js"],
|
|
28
|
+
"skills": ["./skills"]
|
|
29
|
+
},
|
|
13
30
|
"openclaw": {
|
|
14
31
|
"extensions": ["./dist/index.js"]
|
|
15
32
|
},
|
|
16
33
|
"scripts": {
|
|
17
|
-
"build": "tsc",
|
|
34
|
+
"build": "tsc && node scripts/copy-skill.js",
|
|
18
35
|
"postinstall": "node scripts/install-binary.js",
|
|
19
36
|
"prepublishOnly": "npm run build"
|
|
20
37
|
},
|
|
21
38
|
"dependencies": {
|
|
22
39
|
"@opencode-ai/plugin": "^1.16.2"
|
|
23
40
|
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-ai": "*",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
+
"@earendil-works/pi-tui": "*",
|
|
45
|
+
"typebox": "*"
|
|
46
|
+
},
|
|
24
47
|
"devDependencies": {
|
|
48
|
+
"@earendil-works/pi-ai": "*",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "^0.83.0",
|
|
50
|
+
"@earendil-works/pi-tui": "*",
|
|
51
|
+
"typebox": "^1.3.7",
|
|
25
52
|
"typescript": "^5.9.0"
|
|
26
53
|
},
|
|
27
54
|
"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,
|
|
5
|
-
import { homedir
|
|
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
|
|
25
|
-
const
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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.
|