poltertab 1.9.0 → 1.10.0-beta.2

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.
@@ -0,0 +1,320 @@
1
+ "use strict";
2
+
3
+ // Shared pairing-flow primitives used by both `poltertab setup` and
4
+ // `poltertab uploads pair`. These handle the code-exchange protocol but
5
+ // never print command-specific headers — callers print those.
6
+
7
+ const { addTrustedExtensionId } = require("../mcp-server/uploads-config.js");
8
+
9
+ // ── port resolution ────────────────────────────────────────────────────────
10
+
11
+ function resolveWsPort() {
12
+ try {
13
+ return require("../mcp-server/config.js").resolvePort();
14
+ } catch (_) {
15
+ return 7822;
16
+ }
17
+ }
18
+
19
+ // ── server connection ──────────────────────────────────────────────────────
20
+
21
+ // Resolves with the open socket; rejects with err.code === "ECONNREFUSED"
22
+ // if nothing is listening, or err.code === "ETIMEDOUT" on timeout.
23
+ function tryConnectToServer(port, timeoutMs = 3000) {
24
+ return new Promise((resolve, reject) => {
25
+ let WebSocket;
26
+ try { WebSocket = require("ws"); } catch (_) {
27
+ reject(new Error("ws module not available")); return;
28
+ }
29
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`);
30
+ const timer = setTimeout(() => {
31
+ ws.terminate();
32
+ reject(Object.assign(new Error("connection timed out"), { code: "ETIMEDOUT" }));
33
+ }, timeoutMs);
34
+ ws.on("open", () => { clearTimeout(timer); resolve(ws); });
35
+ ws.on("error", (err) => { clearTimeout(timer); reject(err); });
36
+ });
37
+ }
38
+
39
+ // ── pair via running Primary server ────────────────────────────────────────
40
+
41
+ // Uses the running server as the pair host. The server manages codes and
42
+ // sends uploads_pair_result when the user submits a code.
43
+ //
44
+ // rl — an already-open readline.Interface (shared with the wizard).
45
+ //
46
+ // Returns:
47
+ // { ok: true, extensionId } — success
48
+ // { ok: false, reason: 'skipped' } — user pressed Enter with no code
49
+ // { ok: false, reason: 'locked' } — 5-attempt lockout triggered
50
+ // { ok: false, reason: 'error'|... } — connection or server error
51
+ function pairViaRunningServer(serverWs, rl) {
52
+ return new Promise((resolve) => {
53
+ let done = false;
54
+
55
+ const finish = (result) => {
56
+ if (done) return;
57
+ done = true;
58
+ try { serverWs.close(); } catch (_) {}
59
+ resolve(result);
60
+ };
61
+
62
+ const sigintHandler = () => { finish({ ok: false, reason: "skipped" }); process.exit(0); };
63
+ process.once("SIGINT", sigintHandler);
64
+
65
+ serverWs.on("error", (err) => finish({ ok: false, reason: "error", error: err }));
66
+ serverWs.on("close", () => {
67
+ if (!done) finish({ ok: false, reason: "server_disconnected" });
68
+ });
69
+
70
+ serverWs.on("message", (raw) => {
71
+ let msg;
72
+ try { msg = JSON.parse(raw); } catch (_) { return; }
73
+ if (msg.type !== "uploads_pair_result") return;
74
+
75
+ if (msg.ok) {
76
+ finish({ ok: true, extensionId: msg.extensionId });
77
+ } else if (msg.reason === "locked_out") {
78
+ const mins = msg.secsLeft ? Math.ceil(msg.secsLeft / 60) : 15;
79
+ process.stderr.write(
80
+ `\n Too many wrong codes. Pairing is locked for ${mins} minutes.\n` +
81
+ " (You can still pair during the lockout with: poltertab uploads pair --id <extension-id>)\n",
82
+ );
83
+ finish({ ok: false, reason: "locked" });
84
+ } else if (msg.reason === "expired") {
85
+ process.stderr.write(
86
+ " That code expired — the popup is showing a fresh one. Try again.\n",
87
+ );
88
+ askCode();
89
+ } else if (msg.reason === "config_write_failed") {
90
+ process.stderr.write(
91
+ " Pairing failed — could not save the extension ID to config.json.\n" +
92
+ " Check that the file is writable, then try again.\n",
93
+ );
94
+ finish({ ok: false, reason: "config_write_failed" });
95
+ } else {
96
+ const triesLeft =
97
+ msg.triesLeft !== undefined ? ` ${msg.triesLeft} tries left.` : "";
98
+ process.stderr.write(` That code didn’t match.${triesLeft}\n`);
99
+ askCode();
100
+ }
101
+ });
102
+
103
+ function askCode() {
104
+ rl.question(" Code (Enter to skip): ", (ans) => {
105
+ const code = (ans || "").trim().toUpperCase().replace(/\s+/g, "");
106
+ if (!code) { finish({ ok: false, reason: "skipped" }); return; }
107
+ serverWs.send(JSON.stringify({ type: "uploads_pair_submit", code }));
108
+ });
109
+ }
110
+
111
+ askCode();
112
+ });
113
+ }
114
+
115
+ // ── pair via temporary listener (no Primary running) ──────────────────────
116
+
117
+ const TEMP_IDLE_MS = 5 * 60 * 1000; // 5-minute idle timeout
118
+
119
+ // Binds a pairing-only WebSocket server on port. Waits for a
120
+ // chrome-extension:// socket, generates a code for it, then prompts the
121
+ // user to type the code.
122
+ //
123
+ // opts.onStatus(msg) — called when the extension connects; callers use it
124
+ // to print the "extension connected" status line.
125
+ //
126
+ // Returns { ok: true, extensionId } | { ok: false, reason }.
127
+ async function pairViaTempListener(port, rl, { onStatus } = {}) {
128
+ const WebSocket = require("ws");
129
+ const { PairHost } = require("../mcp-server/pair-host.js");
130
+
131
+ const pairHost = new PairHost();
132
+ let idleTimer = null;
133
+ let server = null;
134
+ let resolved = false;
135
+
136
+ const cleanup = () => {
137
+ clearTimeout(idleTimer);
138
+ if (server) {
139
+ try { server.clients.forEach((c) => c.terminate()); } catch (_) {}
140
+ try { server.close(); } catch (_) {}
141
+ }
142
+ };
143
+
144
+ server = new WebSocket.WebSocketServer({ host: "127.0.0.1", port });
145
+
146
+ // Wait for the server to start listening before returning.
147
+ await new Promise((resolve, reject) => {
148
+ server.on("listening", resolve);
149
+ server.on("error", (err) => {
150
+ if (err.code === "EADDRINUSE") {
151
+ reject(Object.assign(new Error(`Port ${port} is in use`), { code: "EADDRINUSE" }));
152
+ } else {
153
+ reject(err);
154
+ }
155
+ });
156
+ });
157
+
158
+ return new Promise((outerResolve) => {
159
+ const finish = (result) => {
160
+ if (resolved) return;
161
+ resolved = true;
162
+ cleanup();
163
+ outerResolve(result);
164
+ };
165
+
166
+ const resetIdle = () => {
167
+ clearTimeout(idleTimer);
168
+ idleTimer = setTimeout(() => finish({ ok: false, reason: "skipped" }), TEMP_IDLE_MS);
169
+ };
170
+
171
+ const sigintHandler = () => { finish({ ok: false, reason: "skipped" }); process.exit(0); };
172
+ process.once("SIGINT", sigintHandler);
173
+
174
+ resetIdle();
175
+
176
+ server.on("connection", (ws, req) => {
177
+ const origin = (req.headers && req.headers.origin) || null;
178
+ resetIdle();
179
+
180
+ // Only accept chrome-extension:// sockets; silently close everything else.
181
+ if (!origin || !origin.startsWith("chrome-extension://")) {
182
+ ws.close(4000, "pairing-only listener");
183
+ return;
184
+ }
185
+
186
+ ws.on("message", (raw) => {
187
+ resetIdle();
188
+ let msg;
189
+ try { msg = JSON.parse(raw); } catch (_) { return; }
190
+
191
+ if (msg.type === "extension_ready") {
192
+ // Generate a code only on first contact; subsequent pings must not regenerate.
193
+ if (!pairHost._socketCodes.has(ws)) {
194
+ pairHost.generateForSocket(ws, origin);
195
+ if (onStatus) onStatus("extension connected — the code is in its popup");
196
+ askCode();
197
+ }
198
+ return;
199
+ }
200
+
201
+ if (msg.type === "ping") {
202
+ // Keep-alive — just reset the idle timer (already done above); do NOT regenerate.
203
+ return;
204
+ }
205
+
206
+ if (msg.type === "pair_code_request") {
207
+ pairHost.handleCodeRequest(ws, origin);
208
+ return;
209
+ }
210
+
211
+ // Popup/options asking whether this extension is paired yet.
212
+ // Reply with uploads_not_paired so the UI can show the pair code.
213
+ if (msg.type === "uploads_get_config") {
214
+ try {
215
+ if (ws.readyState === 1 /* OPEN */) {
216
+ const reply = { type: "uploads_not_paired", pairCode: pairHost._socketCodes.get(ws) || null };
217
+ if (msg._replyId !== undefined) reply._replyId = msg._replyId;
218
+ ws.send(JSON.stringify(reply));
219
+ }
220
+ } catch (_) {}
221
+ return;
222
+ }
223
+
224
+ // Non-pairing message from a Secondary or AI tool dialing in during setup.
225
+ try {
226
+ if (ws.readyState === 1 /* OPEN */) {
227
+ ws.send(JSON.stringify({
228
+ type: "error",
229
+ error: "pairing mode — restart your AI tool for browser control",
230
+ }));
231
+ }
232
+ } catch (_) {}
233
+ });
234
+
235
+ ws.on("close", () => pairHost.onSocketClose(ws));
236
+ ws.on("error", () => pairHost.onSocketClose(ws));
237
+
238
+ function askCode() {
239
+ rl.question(" Code (Enter to skip): ", (ans) => {
240
+ const code = (ans || "").trim().toUpperCase().replace(/\s+/g, "");
241
+ if (!code) { finish({ ok: false, reason: "skipped" }); return; }
242
+
243
+ const result = pairHost.submitCode(code);
244
+ if (result.ok) {
245
+ const bareId = result.extensionId
246
+ .replace(/^chrome-extension:\/\//, "")
247
+ .replace(/\/$/, "");
248
+ let writeErr = null;
249
+ try { addTrustedExtensionId(bareId); } catch (e) { writeErr = e; }
250
+ if (writeErr) {
251
+ // Re-push to the specific extension that matched, not the enclosing socket.
252
+ if (result.extWs && result.extWs.readyState === WebSocket.OPEN) {
253
+ pairHost.generateForSocket(result.extWs, result.extensionId);
254
+ }
255
+ process.stderr.write(
256
+ " Pairing failed — could not save the extension ID to config.json.\n" +
257
+ " Check that the file is writable, then try again.\n",
258
+ );
259
+ finish({ ok: false, reason: "config_write_failed" });
260
+ return;
261
+ }
262
+ try {
263
+ if (result.extWs && result.extWs.readyState === WebSocket.OPEN) {
264
+ result.extWs.send(JSON.stringify({ type: "uploads_paired" }));
265
+ }
266
+ } catch (_) {}
267
+ finish({ ok: true, extensionId: bareId });
268
+ } else if (result.reason === "locked_out") {
269
+ const mins = result.secsLeft ? Math.ceil(result.secsLeft / 60) : 15;
270
+ process.stderr.write(
271
+ `\n Too many wrong codes. Pairing is locked for ${mins} minutes.\n` +
272
+ " (You can still pair during the lockout with: poltertab uploads pair --id <extension-id>)\n",
273
+ );
274
+ finish({ ok: false, reason: "locked" });
275
+ } else if (result.reason === "expired") {
276
+ process.stderr.write(
277
+ " That code expired — the popup is showing a fresh one. Try again.\n",
278
+ );
279
+ askCode();
280
+ } else {
281
+ const triesLeft =
282
+ result.triesLeft !== undefined ? ` ${result.triesLeft} tries left.` : "";
283
+ process.stderr.write(` That code didn’t match.${triesLeft}\n`);
284
+ askCode();
285
+ }
286
+ });
287
+ }
288
+ });
289
+ });
290
+ }
291
+
292
+ // ── poltertab server probe ─────────────────────────────────────────────────
293
+
294
+ // Returns true when the server at port is a poltertab Primary.
295
+ // Prevents treating an unrelated WebSocket server as the pair host.
296
+ async function probeIsPoltertab(port) {
297
+ const http = require("http");
298
+ return new Promise((resolve) => {
299
+ const req = http.get({ host: "127.0.0.1", port, path: "/status", timeout: 2000 }, (res) => {
300
+ let data = "";
301
+ res.on("data", (c) => { data += c; });
302
+ res.on("end", () => {
303
+ try {
304
+ const body = JSON.parse(data);
305
+ resolve(body.role === "primary");
306
+ } catch (_) { resolve(false); }
307
+ });
308
+ });
309
+ req.on("error", () => resolve(false));
310
+ req.on("timeout", () => { req.destroy(); resolve(false); });
311
+ });
312
+ }
313
+
314
+ module.exports = {
315
+ resolveWsPort,
316
+ tryConnectToServer,
317
+ probeIsPoltertab,
318
+ pairViaRunningServer,
319
+ pairViaTempListener,
320
+ };
package/bin/poltertab.js CHANGED
@@ -25,6 +25,11 @@ if (arg === "setup") {
25
25
  });
26
26
  } else if (arg === "--version" || arg === "-v") {
27
27
  console.log(require("../package.json").version);
28
+ } else if (arg === "uploads") {
29
+ require("./uploads.js").run(process.argv.slice(3)).catch((err) => {
30
+ console.error(`\n uploads failed: ${err.message}\n`);
31
+ process.exit(1);
32
+ });
28
33
  } else if (arg === "--help" || arg === "-h") {
29
34
  console.log(`
30
35
  poltertab — browser control for AI agents
@@ -32,10 +37,16 @@ if (arg === "setup") {
32
37
  poltertab start the MCP server (stdio)
33
38
  poltertab setup install skill, CLAUDE.md section, and MCP registration
34
39
  poltertab doctor versions, update check, and extension/server skew
40
+ poltertab uploads manage upload-allowed folders and view the activity log
35
41
  poltertab --version
36
42
 
37
43
  setup flags: --global | --project (skips the prompt)
38
44
  doctor flags: --force (ignore the cached update check), --port <n>
45
+ uploads:
46
+ poltertab uploads list show allowed folders, always-blocked, and status
47
+ poltertab uploads allow <dir> add a directory to the allowlist
48
+ poltertab uploads revoke <dir> remove a directory from the allowlist
49
+ poltertab uploads log [-n N] tail the upload activity log (default: 20 lines)
39
50
 
40
51
  POLTERTAB_HOME where memory and downloads live
41
52
  POLTERTAB_NO_UPDATE_CHECK set to disable the npm version check
package/bin/setup.js CHANGED
@@ -30,6 +30,8 @@ const SNIPPET_SRC = path.join(PKG, "assets", "claude-md-snippet.md");
30
30
  // link would get the previous stable extension, because GitHub's
31
31
  // /releases/latest skips prereleases — a fresh, correctly-followed setup that
32
32
  // lands in version skew.
33
+ const { addAllowlistEntry } = require("../mcp-server/uploads-config.js");
34
+ const { validateAllowlistEntry } = require("../mcp-server/upload-paths.js");
33
35
  const updates = require("../mcp-server/update-check.js");
34
36
  const EXTENSION_URL = updates.extensionUrl(
35
37
  require("../package.json").version,
@@ -39,6 +41,10 @@ const EXTENSION_URL = updates.extensionUrl(
39
41
  // "already installed" — this is the whole idempotency check for step 2.
40
42
  const MARKER = "<!-- poltertab -->";
41
43
 
44
+ // Default folder offered in the uploads prompt. Must never be inside
45
+ // buildAlwaysBlocked — a unit test (H7) pins this.
46
+ const DEFAULT_UPLOAD_FOLDER = "~/Downloads";
47
+
42
48
  const c = {
43
49
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
44
50
  bold: (s) => `\x1b[1m${s}\x1b[0m`,
@@ -150,13 +156,163 @@ async function chooseScope(rl) {
150
156
  }
151
157
  }
152
158
 
153
- async function run(argv = []) {
159
+ // --- Section 1: folder prompt -----------------------------------------------
160
+
161
+ // Shows the uploads-folder prompt and updates the allowlist.
162
+ // Uses the wizard's existing readline interface (rl); never opens a second one.
163
+ // Caller guarantees stdin is a TTY before calling.
164
+ //
165
+ // Exported for direct unit testing.
166
+ async function runFolderPrompt(rl, {
167
+ homeDir = os.homedir(),
168
+ addFn = addAllowlistEntry,
169
+ validateFn = validateAllowlistEntry,
170
+ } = {}) {
171
+ console.log(`\n Let agents upload files from a folder? [${DEFAULT_UPLOAD_FOLDER}]`);
172
+ console.log(" Enter = allow ~/Downloads · type another folder · n = skip");
173
+
174
+ const MAX_RETRIES = 2;
175
+ let retries = 0;
176
+
177
+ for (;;) {
178
+ const raw = (await ask(rl, " > ")).trim();
179
+
180
+ if (raw === "n" || raw === "N") {
181
+ console.log(" – uploads skipped enable later: poltertab uploads allow <dir>");
182
+ return;
183
+ }
184
+
185
+ const folderPath = raw === "" ? DEFAULT_UPLOAD_FOLDER : raw;
186
+
187
+ const v = validateFn(folderPath, homeDir);
188
+ if (!v.ok) {
189
+ console.log(` ${c.yellow("!")} ${v.errorText}`);
190
+ retries++;
191
+ if (retries >= MAX_RETRIES) {
192
+ console.log(" – uploads skipped (poltertab uploads allow <dir> any time)");
193
+ return;
194
+ }
195
+ // re-ask; the loop continues
196
+ continue;
197
+ }
198
+
199
+ const added = addFn(folderPath);
200
+ if (added) {
201
+ console.log(` ${c.green("✓")} uploads folder ${c.dim(folderPath)}`);
202
+ } else {
203
+ console.log(` ${c.green("✓")} uploads folder ${c.dim(folderPath)} (already set)`);
204
+ }
205
+ return;
206
+ }
207
+ }
208
+
209
+ // --- Section 3: setup pairing step ------------------------------------------
210
+
211
+ // Shows the extension instructions and runs the pairing code loop.
212
+ // Uses the wizard's existing readline interface (rl).
213
+ // Caller guarantees stdin is a TTY before calling.
214
+ //
215
+ // opts.pairFlow — injectable pair function for unit tests. Signature:
216
+ // async (rl) => { ok, extensionId? } | { ok: false, reason }
217
+ // When not injected, the real server/temp-listener flow is used.
218
+ //
219
+ // Exported for direct unit testing.
220
+ async function runSetupPairingStep(rl, { pairFlow } = {}) {
221
+ const {
222
+ resolveWsPort,
223
+ tryConnectToServer,
224
+ probeIsPoltertab,
225
+ pairViaRunningServer,
226
+ pairViaTempListener,
227
+ } = require("./pair-flow.js");
228
+
229
+ console.log(`\n ${c.bold("Last step — connect the extension:")}`);
230
+ console.log(` 1. Download and unzip: ${c.cyan(EXTENSION_URL)}`);
231
+ console.log(" 2. chrome://extensions → turn on Developer mode → Load unpacked → pick the folder");
232
+ console.log(" 3. Click the PolterTab icon in Chrome and type the 4-letter code it shows.");
233
+ console.log("");
234
+
235
+ // Use the injected flow (tests) or the real connection flow.
236
+ if (!pairFlow) {
237
+ const port = resolveWsPort();
238
+ let serverWs = null;
239
+ try {
240
+ serverWs = await tryConnectToServer(port);
241
+ // Verify the server is actually poltertab before treating it as the pair host.
242
+ if (!(await probeIsPoltertab(port))) {
243
+ serverWs.terminate();
244
+ serverWs = null;
245
+ console.log(
246
+ ` Port ${port} is in use by something that isn’t poltertab.` +
247
+ " Close it or set a different port in ~/.poltertab/config.json, then retry.",
248
+ );
249
+ return;
250
+ }
251
+ } catch (err) {
252
+ if (err.code !== "ECONNREFUSED" && err.code !== "ENOENT" && err.code !== "ETIMEDOUT") {
253
+ console.log(` Could not reach port ${port}: ${err.message}`);
254
+ return;
255
+ }
256
+ // Nothing listening — use temp listener below.
257
+ }
258
+
259
+ if (serverWs) {
260
+ process.stdout.write(" waiting for the extension to connect…\n");
261
+ pairFlow = (r) => pairViaRunningServer(serverWs, r);
262
+ } else {
263
+ process.stdout.write(" waiting for the extension to connect…\n");
264
+ pairFlow = async (r) => {
265
+ try {
266
+ return await pairViaTempListener(port, r, {
267
+ onStatus: (msg) => process.stdout.write(` ${msg}\n`),
268
+ });
269
+ } catch (e) {
270
+ if (e.code === "EADDRINUSE") {
271
+ console.log(
272
+ ` Port ${port} is in use by something that isn’t poltertab.` +
273
+ " Close it or set a different port in ~/.poltertab/config.json, then retry.",
274
+ );
275
+ return { ok: false, reason: "skipped" };
276
+ }
277
+ throw e;
278
+ }
279
+ };
280
+ }
281
+ }
282
+
283
+ const result = await pairFlow(rl);
284
+
285
+ if (result.ok) {
286
+ const shortId = result.extensionId.slice(0, 8);
287
+ console.log(
288
+ ` ${c.green("✓")} paired extension ${shortId}…` +
289
+ " · uploads panel is live in the extension",
290
+ );
291
+ console.log(c.dim(` undo: poltertab uploads unpair ${result.extensionId}`));
292
+ } else if (result.reason === "skipped") {
293
+ console.log(
294
+ " – pairing skipped run poltertab uploads pair once the extension is loaded",
295
+ );
296
+ } else if (result.reason === "locked") {
297
+ console.log(" Too many wrong codes. Pairing is locked for 15 minutes.");
298
+ }
299
+ // Other reasons (error, server_disconnected): silent skip, don't abort setup.
300
+ }
301
+
302
+ // --- wizard ----------------------------------------------------------------
303
+
304
+ async function run(argv = [], {
305
+ home = os.homedir(),
306
+ cwd = process.cwd(),
307
+ isTTY = process.stdin.isTTY,
308
+ rl: injectedRl = null,
309
+ } = {}) {
154
310
  console.log(`\n ${c.bold("PolterTab")} — browser control for AI agents\n`);
155
311
 
156
- const rl = readline.createInterface({
157
- input: process.stdin,
158
- output: process.stdout,
159
- });
312
+ // Open one readline interface for all prompts. If tests inject one, use it.
313
+ const rl = injectedRl || (isTTY
314
+ ? readline.createInterface({ input: process.stdin, output: process.stdout })
315
+ : null);
160
316
 
161
317
  let scope;
162
318
  try {
@@ -164,12 +320,15 @@ async function run(argv = []) {
164
320
  ? "global"
165
321
  : argv.includes("--project")
166
322
  ? "project"
167
- : await chooseScope(rl);
168
- } finally {
169
- rl.close();
323
+ : rl
324
+ ? await chooseScope(rl)
325
+ : (() => { throw new Error("Choose --global or --project when stdin is not a TTY"); })();
326
+ } catch (err) {
327
+ if (rl && !injectedRl) rl.close();
328
+ throw err;
170
329
  }
171
330
 
172
- const paths = resolvePaths(scope);
331
+ const paths = resolvePaths(scope, { home, cwd });
173
332
  console.log("");
174
333
 
175
334
  // Each step reports what it actually did. A step that fails is reported with
@@ -199,10 +358,31 @@ async function run(argv = []) {
199
358
  console.log(c.dim(` run: claude mcp add --scope ${paths.mcpScope} poltertab poltertab`));
200
359
  }
201
360
 
202
- console.log(`\n ${c.bold("One step left — install the Chrome extension:")}`);
203
- console.log(` ${c.cyan(EXTENSION_URL)}`);
204
- console.log(c.dim(" unzip it, then chrome://extensions → Developer mode → Load unpacked\n"));
205
- console.log(` ${c.dim("Restart Claude Code to pick up the new tools.")}\n`);
361
+ // Section 1: optional uploads folder prompt (TTY only).
362
+ if (isTTY && rl) {
363
+ try {
364
+ await runFolderPrompt(rl, { homeDir: home });
365
+ } catch (err) {
366
+ console.log(` ${c.yellow("!")} uploads folder skipped — ${err.message}`);
367
+ }
368
+ }
369
+
370
+ // Section 3: optional pairing step (TTY only).
371
+ if (isTTY && rl) {
372
+ try {
373
+ await runSetupPairingStep(rl);
374
+ } catch (err) {
375
+ console.log(` ${c.yellow("!")} pairing step failed — ${err.message}`);
376
+ }
377
+ } else {
378
+ // Non-TTY: show extension instructions so the output is still useful.
379
+ console.log(`\n ${c.bold("One step left — install the Chrome extension:")}`);
380
+ console.log(` ${c.cyan(EXTENSION_URL)}`);
381
+ console.log(c.dim(" unzip it, then chrome://extensions → Developer mode → Load unpacked\n"));
382
+ console.log(` ${c.dim("Restart Claude Code to pick up the new tools.")}\n`);
383
+ }
384
+
385
+ if (rl && !injectedRl) rl.close();
206
386
  }
207
387
 
208
388
  module.exports = {
@@ -211,8 +391,11 @@ module.exports = {
211
391
  installSkill,
212
392
  updateClaudeMd,
213
393
  registerServer,
394
+ runFolderPrompt,
395
+ runSetupPairingStep,
214
396
  MARKER,
215
397
  EXTENSION_URL,
398
+ DEFAULT_UPLOAD_FOLDER,
216
399
  };
217
400
 
218
401
  // Guard so the test suite can require this file without triggering a wizard