claude-remote-cli 1.4.3 → 1.6.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.
@@ -0,0 +1,56 @@
1
+ import { execFile, execFileSync } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const SUPPORTED_MIME = {
5
+ 'image/png': { ext: '.png', osascriptClass: '«class PNGf»' },
6
+ 'image/jpeg': { ext: '.jpg', osascriptClass: '«class JPEG»' },
7
+ 'image/gif': { ext: '.gif', osascriptClass: '«class GIFf»' },
8
+ 'image/webp': { ext: '.webp', osascriptClass: '«class PNGf»' },
9
+ };
10
+ let cachedTool;
11
+ export function detectClipboardTool() {
12
+ if (cachedTool !== undefined)
13
+ return cachedTool;
14
+ if (process.platform === 'darwin') {
15
+ cachedTool = 'osascript';
16
+ return cachedTool;
17
+ }
18
+ if (process.env['DISPLAY'] || process.env['WAYLAND_DISPLAY']) {
19
+ try {
20
+ execFileSync('which', ['xclip'], { stdio: 'ignore' });
21
+ cachedTool = 'xclip';
22
+ return cachedTool;
23
+ }
24
+ catch {
25
+ // xclip not found
26
+ }
27
+ }
28
+ cachedTool = null;
29
+ return cachedTool;
30
+ }
31
+ function mimeInfo(mimeType) {
32
+ const info = SUPPORTED_MIME[mimeType];
33
+ if (!info)
34
+ throw new Error(`Unsupported MIME type: ${mimeType}`);
35
+ return info;
36
+ }
37
+ export function extensionForMime(mimeType) {
38
+ return mimeInfo(mimeType).ext;
39
+ }
40
+ export async function setClipboardImage(filePath, mimeType) {
41
+ const tool = detectClipboardTool();
42
+ const info = mimeInfo(mimeType); // throws if unsupported
43
+ if (tool === 'osascript') {
44
+ const script = `set the clipboard to (read (POSIX file "${filePath}") as ${info.osascriptClass})`;
45
+ await execFileAsync('osascript', ['-e', script]);
46
+ return true;
47
+ }
48
+ if (tool === 'xclip') {
49
+ await execFileAsync('xclip', ['-selection', 'clipboard', '-t', mimeType, '-i', filePath]);
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+ export function _resetForTesting() {
55
+ cachedTool = undefined;
56
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import http from 'node:http';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import readline from 'node:readline';
@@ -13,6 +14,7 @@ import * as sessions from './sessions.js';
13
14
  import { setupWebSocket } from './ws.js';
14
15
  import { WorktreeWatcher } from './watcher.js';
15
16
  import { isInstalled as serviceIsInstalled } from './service.js';
17
+ import { extensionForMime, setClipboardImage } from './clipboard.js';
16
18
  const __filename = fileURLToPath(import.meta.url);
17
19
  const __dirname = path.dirname(__filename);
18
20
  const execFileAsync = promisify(execFile);
@@ -127,7 +129,7 @@ async function main() {
127
129
  }
128
130
  const authenticatedTokens = new Set();
129
131
  const app = express();
130
- app.use(express.json());
132
+ app.use(express.json({ limit: '15mb' }));
131
133
  app.use(cookieParser());
132
134
  app.use(express.static(path.join(__dirname, '..', '..', 'public')));
133
135
  const requireAuth = (req, res, next) => {
@@ -377,6 +379,51 @@ async function main() {
377
379
  res.status(404).json({ error: 'Session not found' });
378
380
  }
379
381
  });
382
+ // POST /sessions/:id/image — upload clipboard image, proxy to system clipboard
383
+ const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
384
+ app.post('/sessions/:id/image', requireAuth, async (req, res) => {
385
+ const { data, mimeType } = req.body;
386
+ if (!data || !mimeType) {
387
+ res.status(400).json({ error: 'data and mimeType are required' });
388
+ return;
389
+ }
390
+ if (!ALLOWED_IMAGE_TYPES.includes(mimeType)) {
391
+ res.status(400).json({ error: 'Unsupported image type: ' + mimeType });
392
+ return;
393
+ }
394
+ // base64 is ~33% larger than binary; 10MB binary ≈ 13.3MB base64
395
+ if (data.length > 14 * 1024 * 1024) {
396
+ res.status(413).json({ error: 'Image too large (max 10MB)' });
397
+ return;
398
+ }
399
+ const sessionId = req.params['id'];
400
+ if (!sessions.get(sessionId)) {
401
+ res.status(404).json({ error: 'Session not found' });
402
+ return;
403
+ }
404
+ try {
405
+ const ext = extensionForMime(mimeType);
406
+ const dir = path.join(os.tmpdir(), 'claude-remote-cli', sessionId);
407
+ fs.mkdirSync(dir, { recursive: true });
408
+ const filePath = path.join(dir, 'paste-' + Date.now() + ext);
409
+ fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
410
+ let clipboardSet = false;
411
+ try {
412
+ clipboardSet = await setClipboardImage(filePath, mimeType);
413
+ }
414
+ catch {
415
+ // Clipboard tools failed — fall back to path
416
+ }
417
+ if (clipboardSet) {
418
+ sessions.write(sessionId, '\x16');
419
+ }
420
+ res.json({ path: filePath, clipboardSet });
421
+ }
422
+ catch (err) {
423
+ const message = err instanceof Error ? err.message : 'Image upload failed';
424
+ res.status(500).json({ error: message });
425
+ }
426
+ });
380
427
  // GET /version — check current vs latest
381
428
  app.get('/version', requireAuth, async (_req, res) => {
382
429
  const current = getCurrentVersion();
@@ -1,5 +1,8 @@
1
1
  import pty from 'node-pty';
2
2
  import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
3
6
  // In-memory registry: id -> Session
4
7
  const sessions = new Map();
5
8
  function create({ repoName, repoPath, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24 }) {
@@ -43,6 +46,8 @@ function create({ repoName, repoPath, root, worktreeName, displayName, command,
43
46
  });
44
47
  ptyProcess.onExit(() => {
45
48
  sessions.delete(id);
49
+ const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
50
+ fs.rm(tmpDir, { recursive: true, force: true }, () => { });
46
51
  });
47
52
  return { id, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt };
48
53
  }
@@ -85,4 +90,11 @@ function resize(id, cols, rows) {
85
90
  }
86
91
  session.pty.resize(cols, rows);
87
92
  }
88
- export { create, get, list, kill, resize, updateDisplayName };
93
+ function write(id, data) {
94
+ const session = sessions.get(id);
95
+ if (!session) {
96
+ throw new Error(`Session not found: ${id}`);
97
+ }
98
+ session.pty.write(data);
99
+ }
100
+ export { create, get, list, kill, resize, updateDisplayName, write };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.4.3",
3
+ "version": "1.6.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
package/public/app.js CHANGED
@@ -46,6 +46,10 @@
46
46
  var updateToastText = document.getElementById('update-toast-text');
47
47
  var updateToastBtn = document.getElementById('update-toast-btn');
48
48
  var updateToastDismiss = document.getElementById('update-toast-dismiss');
49
+ var imageToast = document.getElementById('image-toast');
50
+ var imageToastText = document.getElementById('image-toast-text');
51
+ var imageToastInsert = document.getElementById('image-toast-insert');
52
+ var imageToastDismiss = document.getElementById('image-toast-dismiss');
49
53
 
50
54
  // Context menu state
51
55
  var contextMenuTarget = null; // stores { worktreePath, repoPath, name }
@@ -851,6 +855,130 @@
851
855
  }
852
856
  });
853
857
 
858
+ // ── Image Paste Handling ─────────────────────────────────────────────────────
859
+
860
+ var imageUploadInProgress = false;
861
+ var pendingImagePath = null;
862
+
863
+ function showImageToast(text, showInsert) {
864
+ imageToastText.textContent = text;
865
+ imageToastInsert.hidden = !showInsert;
866
+ imageToast.hidden = false;
867
+ }
868
+
869
+ function hideImageToast() {
870
+ imageToast.hidden = true;
871
+ pendingImagePath = null;
872
+ }
873
+
874
+ function autoDismissImageToast(ms) {
875
+ setTimeout(function () {
876
+ if (!pendingImagePath) {
877
+ hideImageToast();
878
+ }
879
+ }, ms);
880
+ }
881
+
882
+ function uploadImage(blob, mimeType) {
883
+ if (imageUploadInProgress) return;
884
+ if (!activeSessionId) return;
885
+
886
+ imageUploadInProgress = true;
887
+ showImageToast('Pasting image\u2026', false);
888
+
889
+ var reader = new FileReader();
890
+ reader.onload = function () {
891
+ var base64 = reader.result.split(',')[1];
892
+
893
+ fetch('/sessions/' + activeSessionId + '/image', {
894
+ method: 'POST',
895
+ headers: { 'Content-Type': 'application/json' },
896
+ body: JSON.stringify({ data: base64, mimeType: mimeType }),
897
+ })
898
+ .then(function (res) {
899
+ if (res.status === 413) {
900
+ showImageToast('Image too large (max 10MB)', false);
901
+ autoDismissImageToast(4000);
902
+ return;
903
+ }
904
+ if (!res.ok) {
905
+ return res.json().then(function (data) {
906
+ showImageToast(data.error || 'Image upload failed', false);
907
+ autoDismissImageToast(4000);
908
+ });
909
+ }
910
+ return res.json().then(function (data) {
911
+ if (data.clipboardSet) {
912
+ showImageToast('Image pasted', false);
913
+ autoDismissImageToast(2000);
914
+ } else {
915
+ pendingImagePath = data.path;
916
+ showImageToast(data.path, true);
917
+ }
918
+ });
919
+ })
920
+ .catch(function () {
921
+ showImageToast('Image upload failed', false);
922
+ autoDismissImageToast(4000);
923
+ })
924
+ .then(function () {
925
+ imageUploadInProgress = false;
926
+ });
927
+ };
928
+
929
+ reader.readAsDataURL(blob);
930
+ }
931
+
932
+ terminalContainer.addEventListener('paste', function (e) {
933
+ if (!e.clipboardData || !e.clipboardData.items) return;
934
+
935
+ var items = e.clipboardData.items;
936
+ for (var i = 0; i < items.length; i++) {
937
+ if (items[i].type.indexOf('image/') === 0) {
938
+ e.preventDefault();
939
+ e.stopPropagation();
940
+ var blob = items[i].getAsFile();
941
+ if (blob) {
942
+ uploadImage(blob, items[i].type);
943
+ }
944
+ return;
945
+ }
946
+ }
947
+ });
948
+
949
+ terminalContainer.addEventListener('dragover', function (e) {
950
+ if (e.dataTransfer && e.dataTransfer.types.indexOf('Files') !== -1) {
951
+ e.preventDefault();
952
+ terminalContainer.classList.add('drag-over');
953
+ }
954
+ });
955
+
956
+ terminalContainer.addEventListener('dragleave', function () {
957
+ terminalContainer.classList.remove('drag-over');
958
+ });
959
+
960
+ terminalContainer.addEventListener('drop', function (e) {
961
+ e.preventDefault();
962
+ terminalContainer.classList.remove('drag-over');
963
+ if (!e.dataTransfer || !e.dataTransfer.files.length) return;
964
+
965
+ var file = e.dataTransfer.files[0];
966
+ if (file.type.indexOf('image/') === 0) {
967
+ uploadImage(file, file.type);
968
+ }
969
+ });
970
+
971
+ imageToastInsert.addEventListener('click', function () {
972
+ if (pendingImagePath && ws && ws.readyState === WebSocket.OPEN) {
973
+ ws.send(pendingImagePath);
974
+ }
975
+ hideImageToast();
976
+ });
977
+
978
+ imageToastDismiss.addEventListener('click', function () {
979
+ hideImageToast();
980
+ });
981
+
854
982
  // ── Keyboard-Aware Viewport ─────────────────────────────────────────────────
855
983
 
856
984
  (function () {
package/public/index.html CHANGED
@@ -89,6 +89,17 @@
89
89
  </div>
90
90
  </div>
91
91
 
92
+ <!-- Image Paste Toast -->
93
+ <div id="image-toast" hidden>
94
+ <div id="image-toast-content">
95
+ <span id="image-toast-text"></span>
96
+ <div id="image-toast-actions">
97
+ <button id="image-toast-insert" class="btn-accent" hidden>Insert Path</button>
98
+ <button id="image-toast-dismiss" aria-label="Dismiss">&times;</button>
99
+ </div>
100
+ </div>
101
+ </div>
102
+
92
103
  </div>
93
104
 
94
105
  <!-- New Session Dialog -->
package/public/style.css CHANGED
@@ -417,6 +417,7 @@ html, body {
417
417
 
418
418
  /* ===== Touch Toolbar ===== */
419
419
  #toolbar {
420
+ display: none;
420
421
  flex-shrink: 0;
421
422
  background: var(--surface);
422
423
  border-top: 1px solid var(--border);
@@ -993,6 +994,10 @@ dialog#delete-worktree-dialog h2 {
993
994
  .tb-btn.tb-arrow {
994
995
  font-size: 1.2rem;
995
996
  }
997
+
998
+ #toolbar {
999
+ display: block;
1000
+ }
996
1001
  }
997
1002
 
998
1003
  /* ===== Scrollbar ===== */
@@ -1008,3 +1013,46 @@ dialog#delete-worktree-dialog h2 {
1008
1013
  background: var(--border);
1009
1014
  border-radius: 4px;
1010
1015
  }
1016
+
1017
+ /* ===== Image Paste Toast ===== */
1018
+ #image-toast {
1019
+ position: fixed;
1020
+ bottom: 60px;
1021
+ left: 50%;
1022
+ transform: translateX(-50%);
1023
+ z-index: 1000;
1024
+ background: #2d2d2d;
1025
+ border: 1px solid #555;
1026
+ border-radius: 8px;
1027
+ padding: 8px 14px;
1028
+ color: #d4d4d4;
1029
+ font-size: 13px;
1030
+ max-width: 90vw;
1031
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4);
1032
+ }
1033
+
1034
+ #image-toast-content {
1035
+ display: flex;
1036
+ align-items: center;
1037
+ gap: 10px;
1038
+ }
1039
+
1040
+ #image-toast-actions {
1041
+ display: flex;
1042
+ gap: 6px;
1043
+ align-items: center;
1044
+ }
1045
+
1046
+ #image-toast-dismiss {
1047
+ background: none;
1048
+ border: none;
1049
+ color: #999;
1050
+ cursor: pointer;
1051
+ font-size: 16px;
1052
+ padding: 2px 6px;
1053
+ }
1054
+
1055
+ #terminal-container.drag-over {
1056
+ outline: 2px dashed #007acc;
1057
+ outline-offset: -2px;
1058
+ }