claude-bridge-cli 2.0.3 → 2.0.6

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.
Files changed (2) hide show
  1. package/lib/bridge.js +124 -9
  2. package/package.json +2 -2
package/lib/bridge.js CHANGED
@@ -64,6 +64,92 @@ function saveImages(images, sessionId) {
64
64
  return saved;
65
65
  }
66
66
 
67
+ // ── Per-session file exchange (the Files drawer) ──────────────────────────
68
+ // Same per-session folder used for images doubles as a two-way file drawer:
69
+ // the user uploads ANY file here (Claude reads it), and Claude drops files here
70
+ // (the user downloads them). Old files pruned on access.
71
+ const MAX_FILE_BYTES = 45 * 1024 * 1024;
72
+ const FILE_PRUNE_DAYS = 14;
73
+ const FILE_NAME_RE = /^[A-Za-z0-9._ ()+\-]+$/;
74
+ const EXT_MIME = {
75
+ drawio: "application/xml", pdf: "application/pdf", json: "application/json",
76
+ csv: "text/csv", txt: "text/plain", md: "text/markdown", xml: "application/xml",
77
+ svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
78
+ gif: "image/gif", webp: "image/webp", zip: "application/zip", gz: "application/gzip",
79
+ tar: "application/x-tar", html: "text/html", css: "text/css", js: "text/javascript",
80
+ ts: "text/plain", py: "text/x-python", cs: "text/plain", java: "text/x-java",
81
+ yml: "text/yaml", yaml: "text/yaml", sql: "text/plain", log: "text/plain", sh: "text/x-sh",
82
+ };
83
+ function mimeFor(name) {
84
+ const e = path.extname(name || "").slice(1).toLowerCase();
85
+ return EXT_MIME[e] || "application/octet-stream";
86
+ }
87
+ function sanitizeAnyFilename(name) {
88
+ let base = path.basename((name || "").trim())
89
+ .replace(/[^A-Za-z0-9._ ()+\-]/g, "_")
90
+ .replace(/^[._ ]+|[._ ]+$/g, "");
91
+ return (base || "upload.bin").slice(0, 200);
92
+ }
93
+ function sessionFilesDir(sid) {
94
+ if (!/^[A-Za-z0-9._-]+$/.test(sid || "")) throw new Error("bad session id");
95
+ return path.join(dataDir(), "images", sid);
96
+ }
97
+ function pruneSessionFiles(dir) {
98
+ const cutoff = Date.now() - FILE_PRUNE_DAYS * 86400 * 1000;
99
+ let entries; try { entries = fs.readdirSync(dir); } catch { return; }
100
+ for (const f of entries) {
101
+ const fp = path.join(dir, f);
102
+ try { const st = fs.statSync(fp); if (st.isFile() && st.mtimeMs < cutoff) fs.unlinkSync(fp); } catch {}
103
+ }
104
+ }
105
+ function listSessionFiles(sid) {
106
+ const dir = sessionFilesDir(sid);
107
+ pruneSessionFiles(dir);
108
+ let entries; try { entries = fs.readdirSync(dir); } catch { return []; }
109
+ const out = [];
110
+ for (const f of entries.sort()) {
111
+ const fp = path.join(dir, f);
112
+ try {
113
+ const st = fs.statSync(fp);
114
+ if (st.isFile()) out.push({ name: f, size: st.size, mtime: Math.floor(st.mtimeMs / 1000), mime: mimeFor(f) });
115
+ } catch {}
116
+ }
117
+ return out;
118
+ }
119
+ function readSessionFile(sid, name) {
120
+ const dir = sessionFilesDir(sid);
121
+ const safe = path.basename(name || "");
122
+ if (!safe || safe !== name || !FILE_NAME_RE.test(safe)) throw new Error("bad file name");
123
+ const fp = path.join(dir, safe);
124
+ if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) { const e = new Error("file not found"); e.notFound = true; throw e; }
125
+ if (fs.statSync(fp).size > MAX_FILE_BYTES) throw new Error("file exceeds limit — too large to transfer");
126
+ const buf = fs.readFileSync(fp);
127
+ return { name: safe, size: buf.length, mime: mimeFor(safe), data_base64: buf.toString("base64") };
128
+ }
129
+ function saveSessionFile(sid, name, dataB64) {
130
+ const dir = sessionFilesDir(sid);
131
+ fs.mkdirSync(dir, { recursive: true });
132
+ pruneSessionFiles(dir);
133
+ if (typeof dataB64 !== "string") throw new Error("missing data_base64");
134
+ const mm = dataB64.match(/^data:[^;]+;base64,([\s\S]+)$/);
135
+ if (mm) dataB64 = mm[1];
136
+ const buf = Buffer.from(dataB64, "base64");
137
+ if (buf.length > MAX_FILE_BYTES) throw new Error("file too large");
138
+ const final = `${Date.now()}-${sanitizeAnyFilename(name || "upload.bin")}`;
139
+ const fp = path.join(dir, final);
140
+ fs.writeFileSync(fp, buf, { mode: 0o600 });
141
+ return { name: final, size: buf.length, mime: mimeFor(final), path: fp };
142
+ }
143
+ function deleteSessionFile(sid, name) {
144
+ const dir = sessionFilesDir(sid);
145
+ const safe = path.basename(name || "");
146
+ if (!safe || safe !== name || !FILE_NAME_RE.test(safe)) throw new Error("bad file name");
147
+ const fp = path.join(dir, safe);
148
+ if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) { const e = new Error("file not found"); e.notFound = true; throw e; }
149
+ fs.unlinkSync(fp);
150
+ return { deleted: safe };
151
+ }
152
+
67
153
  function splitUserTextAndImages(text) {
68
154
  const m = text.match(/\nThe user attached \d+ image\(s\) at these absolute paths\. Use the Read tool to view them:\n([\s\S]+)$/);
69
155
  if (!m) return { cleanText: text, imagePaths: [] };
@@ -466,18 +552,20 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
466
552
  const isWin = process.platform === "win32";
467
553
  const isCmdShim = isWin && config.claudeBin.endsWith(".cmd");
468
554
 
469
- // Fall back to @file syntax in two cases:
555
+ // Pass the prompt via a temp file (@file, read by claude itself) instead of as
556
+ // a command-line argument when:
470
557
  // 1) Prompt exceeds Windows cmd.exe's ~8K arg limit (threshold 6000).
471
- // 2) Prompt contains a newline AND we're on Windows with a .cmd shim
472
- // cmd.exe parses arg strings line-by-line and silently truncates
473
- // at the first \n, even with Node.js's proper argv quoting. Without
474
- // this fallback, any multi-line prompt to Claude on Windows lost
475
- // everything after line 1, and the model saw a stub message + the
476
- // header but none of the body. Manifested as "your message got
477
- // cut off" responses for prompts that LOOK short.
558
+ // 2) We're on Windows with a .cmd shim — period. The shim runs through
559
+ // `cmd.exe /c`, which RE-PARSES the prompt with CMD's own rules: a double
560
+ // quote (") or a metacharacter (% & | < > ^) breaks the command line and
561
+ // surfaces as the cryptic "The system cannot find the file specified";
562
+ // a newline silently truncates the prompt at line 1. Node's argv quoting
563
+ // can't protect against cmd.exe's second parse. Passing @file sidesteps ALL
564
+ // of it, so do it for EVERY cmd-shim prompt — not just multi-line ones.
565
+ // (Previously only triggered on \r|\n, so quoted single-line prompts failed.)
478
566
  const useTempFile =
479
567
  finalPrompt.length > 6000 ||
480
- (isWin && isCmdShim && /\r|\n/.test(finalPrompt));
568
+ (isWin && isCmdShim);
481
569
  let tmpFile = null;
482
570
  if (useTempFile) {
483
571
  tmpFile = path.join(os.tmpdir(), `claude-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
@@ -767,6 +855,33 @@ function startBridge(config) {
767
855
  }
768
856
  }
769
857
 
858
+ // Session files — list (GET) / upload (POST)
859
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/files$/);
860
+ if (m) {
861
+ if (req.method === "GET") {
862
+ try { send(200, { files: listSessionFiles(m[1]) }); } catch (e) { send(400, { error: String(e.message || e) }); }
863
+ return;
864
+ }
865
+ if (req.method === "POST") {
866
+ const body = await readBody(req);
867
+ try { send(200, saveSessionFile(m[1], body.name, body.data_base64 || body.data)); }
868
+ catch (e) { send(400, { error: String(e.message || e) }); }
869
+ return;
870
+ }
871
+ }
872
+ // Session files — download one (GET) / remove one (DELETE)
873
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/files\/(.+)$/);
874
+ if (m && req.method === "GET") {
875
+ try { send(200, readSessionFile(m[1], decodeURIComponent(m[2]))); }
876
+ catch (e) { send(e.notFound ? 404 : 400, { error: String(e.message || e) }); }
877
+ return;
878
+ }
879
+ if (m && req.method === "DELETE") {
880
+ try { send(200, deleteSessionFile(m[1], decodeURIComponent(m[2]))); }
881
+ catch (e) { send(e.notFound ? 404 : 400, { error: String(e.message || e) }); }
882
+ return;
883
+ }
884
+
770
885
  send(404, { error: "not found" });
771
886
  });
772
887
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-bridge-cli",
3
- "version": "2.0.3",
3
+ "version": "2.0.6",
4
4
  "description": "Use Claude Code from your browser. Runs a local server that connects your browser tools to the Claude CLI.",
5
5
  "main": "lib/bridge.js",
6
6
  "bin": {
@@ -23,4 +23,4 @@
23
23
  "engines": {
24
24
  "node": ">=18.0.0"
25
25
  }
26
- }
26
+ }