culater 1.0.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 ADDED
@@ -0,0 +1,66 @@
1
+ # culater
2
+
3
+ Remote terminal for Claude Code - c(See) you later!
4
+
5
+ Access Claude Code from your phone via a secure tunnel.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npx culater
11
+ ```
12
+
13
+ That's it! You'll get a URL and password to access from your phone.
14
+
15
+ ## Requirements
16
+
17
+ - Node.js 18+
18
+ - [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/)
19
+
20
+ ```bash
21
+ # macOS
22
+ brew install cloudflared
23
+
24
+ # Linux
25
+ sudo apt install cloudflared
26
+ ```
27
+
28
+ ## Options
29
+
30
+ ```
31
+ -p, --password <pass> Set password (default: random 5-char)
32
+ -n, --ntfy <topic> Enable ntfy.sh push notifications
33
+ -d, --dir <path> Working directory (default: current)
34
+ -h, --help Show help
35
+ ```
36
+
37
+ ## Examples
38
+
39
+ ```bash
40
+ # Random password, current directory
41
+ npx culater
42
+
43
+ # Custom password
44
+ npx culater -p mysecret
45
+
46
+ # With push notification
47
+ npx culater -n my-ntfy-topic
48
+
49
+ # Specific directory
50
+ npx culater -d ~/projects/myapp
51
+ ```
52
+
53
+ ## Features
54
+
55
+ - Mobile-optimized terminal UI
56
+ - Touch scrolling with momentum
57
+ - Quick action buttons (/, Esc, ↓, Enter)
58
+ - Auto-reconnect on disconnect
59
+ - Keyboard-aware button positioning
60
+ - Streaming indicator
61
+ - Password protection
62
+ - Secure cloudflare tunnel
63
+
64
+ ## License
65
+
66
+ MIT
package/bin/culater.js ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync, spawn } = require('child_process');
4
+ const path = require('path');
5
+
6
+ // Check for cloudflared
7
+ try {
8
+ execSync('which cloudflared', { stdio: 'ignore' });
9
+ } catch {
10
+ console.error('\x1b[31mError: cloudflared is not installed\x1b[0m\n');
11
+ console.error('Install it with:');
12
+ console.error(' \x1b[36mbrew install cloudflared\x1b[0m (macOS)');
13
+ console.error(' \x1b[36msudo apt install cloudflared\x1b[0m (Linux)');
14
+ console.error('\nOr visit: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/\n');
15
+ process.exit(1);
16
+ }
17
+
18
+ // Generate random password
19
+ function randomPass() {
20
+ const chars = 'abcdefghijkmnpqrstuvwxyz23456789';
21
+ let pass = '';
22
+ for (let i = 0; i < 5; i++) {
23
+ pass += chars[Math.floor(Math.random() * chars.length)];
24
+ }
25
+ return pass;
26
+ }
27
+
28
+ // Parse args
29
+ const args = process.argv.slice(2);
30
+ let password = randomPass();
31
+ let ntfyTopic = '';
32
+ let workDir = process.cwd();
33
+
34
+ for (let i = 0; i < args.length; i++) {
35
+ if (args[i] === '-p' || args[i] === '--password') {
36
+ password = args[++i];
37
+ } else if (args[i] === '-n' || args[i] === '--ntfy') {
38
+ ntfyTopic = args[++i];
39
+ } else if (args[i] === '-d' || args[i] === '--dir') {
40
+ workDir = args[++i];
41
+ } else if (args[i] === '-h' || args[i] === '--help') {
42
+ console.log(`
43
+ \x1b[1mculater\x1b[0m - Remote terminal for Claude Code
44
+
45
+ \x1b[1mUSAGE:\x1b[0m
46
+ npx culater [options]
47
+
48
+ \x1b[1mOPTIONS:\x1b[0m
49
+ -p, --password <pass> Set password (default: random 5-char)
50
+ -n, --ntfy <topic> Enable ntfy.sh push notifications
51
+ -d, --dir <path> Working directory (default: current)
52
+ -h, --help Show this help
53
+
54
+ \x1b[1mEXAMPLES:\x1b[0m
55
+ npx culater
56
+ npx culater -p mysecret
57
+ npx culater -p mysecret -n my-ntfy-topic
58
+ npx culater -d ~/projects/myapp
59
+ `);
60
+ process.exit(0);
61
+ }
62
+ }
63
+
64
+ // Set env and run server
65
+ process.env.REMOTE_PASSWORD = password;
66
+ process.env.NTFY_TOPIC = ntfyTopic;
67
+ process.env.WORK_DIR = workDir;
68
+
69
+ require('../lib/server.js');
package/lib/server.js ADDED
@@ -0,0 +1,526 @@
1
+ const http = require('http');
2
+ const WebSocket = require('ws');
3
+ const { spawn, execSync } = require('child_process');
4
+ const pty = require('node-pty');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const os = require('os');
8
+
9
+ const PASSWORD = process.env.REMOTE_PASSWORD || 'changeme';
10
+ const PORT = process.env.PORT || 3456;
11
+ const WORK_DIR = process.env.WORK_DIR || process.cwd();
12
+ const NTFY_TOPIC = process.env.NTFY_TOPIC || '';
13
+
14
+ const SESSION_TOKEN = crypto.randomBytes(16).toString('hex');
15
+
16
+ // Detect shell
17
+ const SHELL = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
18
+
19
+ function checkAuth(req) {
20
+ const url = new URL(req.url, `http://${req.headers.host}`);
21
+ if (url.searchParams.get('token') === SESSION_TOKEN) return true;
22
+ const cookies = req.headers.cookie || '';
23
+ return cookies.includes(`token=${SESSION_TOKEN}`);
24
+ }
25
+
26
+ const server = http.createServer((req, res) => {
27
+ const url = new URL(req.url, `http://${req.headers.host}`);
28
+
29
+ if (url.pathname === '/auth' && req.method === 'POST') {
30
+ let body = '';
31
+ req.on('data', chunk => body += chunk);
32
+ req.on('end', () => {
33
+ const params = new URLSearchParams(body);
34
+ if (params.get('password') === PASSWORD) {
35
+ res.writeHead(302, {
36
+ 'Set-Cookie': `token=${SESSION_TOKEN}; Path=/; HttpOnly; SameSite=Strict`,
37
+ 'Location': '/'
38
+ });
39
+ } else {
40
+ res.writeHead(302, { 'Location': '/?error=1' });
41
+ }
42
+ res.end();
43
+ });
44
+ return;
45
+ }
46
+
47
+ if (!checkAuth(req)) {
48
+ res.writeHead(200, { 'Content-Type': 'text/html' });
49
+ res.end(LOGIN_HTML);
50
+ return;
51
+ }
52
+
53
+ res.writeHead(200, { 'Content-Type': 'text/html' });
54
+ res.end(TERMINAL_HTML);
55
+ });
56
+
57
+ const wss = new WebSocket.Server({ noServer: true });
58
+
59
+ server.on('upgrade', (req, socket, head) => {
60
+ if (!checkAuth(req)) {
61
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
62
+ return socket.destroy();
63
+ }
64
+ wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req));
65
+ });
66
+
67
+ wss.on('connection', (ws) => {
68
+ console.log('Terminal connected');
69
+
70
+ let ptyProcess = null;
71
+ let cols = 80, rows = 24;
72
+
73
+ function startPty() {
74
+ try {
75
+ ptyProcess = pty.spawn(SHELL, ['-l'], {
76
+ name: 'xterm-256color',
77
+ cols,
78
+ rows,
79
+ cwd: WORK_DIR,
80
+ env: {
81
+ ...process.env,
82
+ TERM: 'xterm-256color'
83
+ }
84
+ });
85
+ console.log('PTY started, pid:', ptyProcess.pid);
86
+
87
+ ptyProcess.onData(data => {
88
+ if (ws.readyState === WebSocket.OPEN) {
89
+ ws.send(data);
90
+ }
91
+ });
92
+
93
+ ptyProcess.onExit(({ exitCode }) => {
94
+ console.log('PTY exited:', exitCode);
95
+ ptyProcess = null;
96
+ if (ws.readyState === WebSocket.OPEN) {
97
+ ws.send('\r\n\x1b[33m[Process exited with code ' + exitCode + ']\x1b[0m\r\n');
98
+ ws.send('\x1b[33mPress Enter to restart Claude...\x1b[0m\r\n');
99
+ }
100
+ });
101
+
102
+ // Auto-start claude after shell is ready
103
+ setTimeout(() => {
104
+ if (ptyProcess) ptyProcess.write('claude\r');
105
+ }, 200);
106
+
107
+ } catch (err) {
108
+ console.error('PTY spawn failed:', err);
109
+ ws.send('\x1b[31mFailed to start terminal: ' + err.message + '\x1b[0m\r\n');
110
+ }
111
+ }
112
+
113
+ startPty();
114
+
115
+ ws.on('message', msg => {
116
+ try {
117
+ const data = JSON.parse(msg.toString());
118
+ if (data.type === 'input') {
119
+ if (ptyProcess) {
120
+ ptyProcess.write(data.data);
121
+ } else if (data.data === '\r' || data.data === '\n') {
122
+ ws.send('\x1b[2J\x1b[H');
123
+ ws.send('\x1b[32mRestarting...\x1b[0m\r\n');
124
+ startPty();
125
+ }
126
+ } else if (data.type === 'resize') {
127
+ cols = data.cols;
128
+ rows = data.rows;
129
+ if (ptyProcess) ptyProcess.resize(cols, rows);
130
+ } else if (data.type === 'stop') {
131
+ if (ptyProcess) {
132
+ console.log('Stop requested, killing PTY');
133
+ ptyProcess.kill();
134
+ }
135
+ } else if (data.type === 'ping') {
136
+ ws.send(JSON.stringify({type:'pong'}));
137
+ }
138
+ } catch {
139
+ if (ptyProcess) ptyProcess.write(msg.toString());
140
+ }
141
+ });
142
+
143
+ ws.on('close', () => {
144
+ console.log('Terminal disconnected');
145
+ if (ptyProcess) ptyProcess.kill();
146
+ });
147
+
148
+ const pingInterval = setInterval(() => {
149
+ if (ws.readyState === WebSocket.OPEN) ws.ping();
150
+ }, 15000);
151
+ ws.on('close', () => clearInterval(pingInterval));
152
+
153
+ ws.isAlive = true;
154
+ ws.on('pong', () => { ws.isAlive = true; });
155
+ const aliveInterval = setInterval(() => {
156
+ if (!ws.isAlive) {
157
+ console.log('Connection dead, terminating');
158
+ return ws.terminate();
159
+ }
160
+ ws.isAlive = false;
161
+ }, 30000);
162
+ ws.on('close', () => clearInterval(aliveInterval));
163
+ });
164
+
165
+ async function sendNotification(url) {
166
+ if (!NTFY_TOPIC) return;
167
+ try {
168
+ await fetch(`https://ntfy.sh/${NTFY_TOPIC}`, {
169
+ method: 'POST',
170
+ headers: { 'Title': 'culater', 'Click': url, 'Tags': 'computer' },
171
+ body: url
172
+ });
173
+ console.log(`Notified: ntfy.sh/${NTFY_TOPIC}`);
174
+ } catch (e) {
175
+ console.error('Notification failed:', e.message);
176
+ }
177
+ }
178
+
179
+ let tunnelProcess = null;
180
+
181
+ async function createTunnel() {
182
+ tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${PORT}`], {
183
+ stdio: ['ignore', 'pipe', 'pipe']
184
+ });
185
+
186
+ let urlFound = false;
187
+ tunnelProcess.stderr.on('data', data => {
188
+ const match = data.toString().match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
189
+ if (match && !urlFound) {
190
+ urlFound = true;
191
+ const url = match[0];
192
+ console.log('\n' + '='.repeat(50));
193
+ console.log(' culater - c(See) you later!');
194
+ console.log('='.repeat(50));
195
+ console.log(`\n URL: ${url}`);
196
+ console.log(` Password: ${PASSWORD}`);
197
+ console.log(` Directory: ${WORK_DIR}`);
198
+ console.log('='.repeat(50) + '\n');
199
+
200
+ setTimeout(async () => {
201
+ try {
202
+ await fetch(url, { method: 'HEAD' });
203
+ sendNotification(url);
204
+ } catch {
205
+ setTimeout(() => sendNotification(url), 2000);
206
+ }
207
+ }, 1500);
208
+ }
209
+ });
210
+
211
+ tunnelProcess.on('close', code => {
212
+ if (code !== 0) {
213
+ console.log('Tunnel reconnecting...');
214
+ setTimeout(createTunnel, 5000);
215
+ }
216
+ });
217
+ }
218
+
219
+ server.listen(PORT, async () => {
220
+ console.log(`Server on port ${PORT}`);
221
+ await createTunnel();
222
+ });
223
+
224
+ process.on('SIGINT', () => {
225
+ console.log('\nShutting down...');
226
+ if (tunnelProcess) tunnelProcess.kill();
227
+ wss.clients.forEach(ws => ws.close());
228
+ server.close();
229
+ process.exit(0);
230
+ });
231
+
232
+ const LOGIN_HTML = `<!DOCTYPE html>
233
+ <html>
234
+ <head>
235
+ <meta charset="UTF-8">
236
+ <meta name="viewport" content="width=device-width, initial-scale=1">
237
+ <title>culater</title>
238
+ <style>
239
+ *{box-sizing:border-box;margin:0;padding:0}
240
+ body{font-family:-apple-system,sans-serif;height:100vh;display:flex;align-items:center;justify-content:center;background:#1a1a2e}
241
+ .box{background:#fff;padding:32px;border-radius:12px;width:90%;max-width:320px}
242
+ h1{font-size:24px;margin-bottom:24px;text-align:center}
243
+ input{width:100%;padding:14px;border:1px solid #ddd;border-radius:8px;font-size:16px;margin-bottom:16px}
244
+ button{width:100%;padding:14px;background:#007AFF;color:#fff;border:none;border-radius:8px;font-size:16px;cursor:pointer}
245
+ .error{color:#c62828;text-align:center;margin-bottom:16px}
246
+ </style>
247
+ </head>
248
+ <body>
249
+ <div class="box">
250
+ <h1>culater</h1>
251
+ <div class="error" id="e"></div>
252
+ <form action="/auth" method="POST">
253
+ <input type="password" name="password" placeholder="Password" autofocus>
254
+ <button type="submit">Enter</button>
255
+ </form>
256
+ </div>
257
+ <script>if(location.search.includes('error'))document.getElementById('e').textContent='Invalid password'</script>
258
+ </body>
259
+ </html>`;
260
+
261
+ const TERMINAL_HTML = `<!DOCTYPE html>
262
+ <html>
263
+ <head>
264
+ <meta charset="UTF-8">
265
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
266
+ <title>culater</title>
267
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
268
+ <style>
269
+ *{margin:0;padding:0}
270
+ html,body{height:100%;background:#1a1a2e;overflow:hidden}
271
+ #terminal{height:100%}
272
+ .xterm{height:100%;padding:8px}
273
+ .xterm-viewport{overflow-y:auto!important;-webkit-overflow-scrolling:touch!important;scroll-behavior:smooth}
274
+ #reconnect{position:fixed;bottom:20px;right:60px;padding:12px 24px;background:#007AFF;color:#fff;border:none;border-radius:8px;font-size:16px;cursor:pointer;display:none;z-index:100}
275
+ #reconnect:active{background:#0056b3}
276
+ #action-btns{position:fixed;bottom:16px;right:0;display:flex;flex-direction:column;gap:6px;z-index:100;padding-right:0}
277
+ #action-btns button{width:54px;height:44px;color:#fff;border:none;border-radius:10px 0 0 10px;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center}
278
+ #action-btns button:active{opacity:0.6}
279
+ #slash-btn{background:rgba(142,142,147,0.8)}
280
+ #esc-btn{background:rgba(255,149,0,0.8)}
281
+ #enter-btn{background:rgba(52,199,89,0.8)}
282
+ #arrow-down{background:rgba(88,86,214,0.8)}
283
+ #status{position:fixed;top:8px;right:70px;padding:4px 8px;border-radius:4px;font-size:12px;color:#fff;background:#2d5a27}
284
+ #status.disconnected{background:#8b2635}
285
+ #stop{position:fixed;top:6px;right:0;width:44px;height:32px;background:rgba(198,40,40,0.8);color:#fff;border:none;border-radius:6px 0 0 6px;font-size:18px;cursor:pointer;z-index:100;display:flex;align-items:center;justify-content:center}
286
+ #stop:active{opacity:0.6}
287
+ #streaming{position:fixed;top:8px;left:8px;padding:6px 12px;border-radius:4px;font-size:12px;color:#fff;background:#5c4d9a;display:none;align-items:center;gap:6px}
288
+ #streaming .dot{width:6px;height:6px;background:#fff;border-radius:50%;animation:pulse 1s infinite}
289
+ #streaming .dot:nth-child(2){animation-delay:0.2s}
290
+ #streaming .dot:nth-child(3){animation-delay:0.4s}
291
+ @keyframes pulse{0%,100%{opacity:0.3}50%{opacity:1}}
292
+ #scroll-lock{position:fixed;top:44px;right:0;width:44px;height:32px;background:rgba(0,122,255,0.8);color:#fff;border:none;border-radius:6px 0 0 6px;font-size:16px;cursor:pointer;z-index:100;display:flex;align-items:center;justify-content:center}
293
+ #scroll-lock.locked{background:rgba(255,149,0,0.8)}
294
+ #scroll-lock:active{opacity:0.8}
295
+ </style>
296
+ </head>
297
+ <body>
298
+ <div id="status">Connected</div>
299
+ <button id="stop" title="Stop Claude">■</button>
300
+ <div id="streaming"><span class="dot"></span><span class="dot"></span><span class="dot"></span>Streaming</div>
301
+ <button id="reconnect">Reconnect</button>
302
+ <button id="scroll-lock">⇊</button>
303
+ <div id="action-btns">
304
+ <button id="slash-btn" title="Send /">/</button>
305
+ <button id="esc-btn" title="Send Escape">Esc</button>
306
+ <button id="arrow-down" title="Send Down Arrow">↓</button>
307
+ <button id="enter-btn" title="Send Enter">⏎</button>
308
+ </div>
309
+ <div id="terminal"></div>
310
+ <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
311
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
312
+ <script>
313
+ const term = new Terminal({cursorBlink:true,fontSize:14,fontFamily:'Menlo,Monaco,monospace',theme:{background:'#1a1a2e'},scrollback:5000,smoothScrollDuration:100,fastScrollModifier:'none',fastScrollSensitivity:5,scrollSensitivity:3});
314
+ const fit = new FitAddon.FitAddon();
315
+ const statusEl = document.getElementById('status');
316
+ const reconnectBtn = document.getElementById('reconnect');
317
+ const streamingEl = document.getElementById('streaming');
318
+ const scrollLockBtn = document.getElementById('scroll-lock');
319
+ const stopBtn = document.getElementById('stop');
320
+ const arrowDownBtn = document.getElementById('arrow-down');
321
+ const enterBtn = document.getElementById('enter-btn');
322
+ const escBtn = document.getElementById('esc-btn');
323
+ const slashBtn = document.getElementById('slash-btn');
324
+ let ws;
325
+ let streamTimeout;
326
+ let autoScroll = true;
327
+ let userScrolling = false;
328
+
329
+ term.loadAddon(fit);
330
+ term.open(document.getElementById('terminal'));
331
+ fit.fit();
332
+
333
+ const viewport = document.querySelector('.xterm-viewport');
334
+ if (viewport) {
335
+ viewport.addEventListener('scroll', () => {
336
+ const atBottom = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 50;
337
+ if (!atBottom && !userScrolling) {
338
+ userScrolling = true;
339
+ autoScroll = false;
340
+ updateScrollBtn();
341
+ }
342
+ });
343
+
344
+ viewport.addEventListener('wheel', (e) => {
345
+ e.preventDefault();
346
+ const multiplier = 6;
347
+ viewport.scrollTop += e.deltaY * multiplier;
348
+ }, {passive: false});
349
+
350
+ let touchStartY = 0;
351
+ let lastTouchY = 0;
352
+ let velocity = 0;
353
+ let momentumId = null;
354
+ const termEl = document.getElementById('terminal');
355
+
356
+ termEl.addEventListener('touchstart', (e) => {
357
+ if (e.target.closest('button')) return;
358
+ cancelAnimationFrame(momentumId);
359
+ touchStartY = e.touches[0].clientY;
360
+ lastTouchY = touchStartY;
361
+ velocity = 0;
362
+ }, {passive: true, capture: true});
363
+
364
+ termEl.addEventListener('touchmove', (e) => {
365
+ if (e.target.closest('button')) return;
366
+ e.preventDefault();
367
+ e.stopPropagation();
368
+ const touchY = e.touches[0].clientY;
369
+ const delta = (lastTouchY - touchY) * 6;
370
+ velocity = lastTouchY - touchY;
371
+ lastTouchY = touchY;
372
+ viewport.scrollTop += delta;
373
+ }, {passive: false, capture: true});
374
+
375
+ termEl.addEventListener('touchend', (e) => {
376
+ if (e.target.closest('button')) return;
377
+ const decelerate = () => {
378
+ velocity *= 0.9;
379
+ if (Math.abs(velocity) > 0.5) {
380
+ viewport.scrollTop += velocity * 5;
381
+ momentumId = requestAnimationFrame(decelerate);
382
+ }
383
+ };
384
+ momentumId = requestAnimationFrame(decelerate);
385
+ }, {passive: true, capture: true});
386
+ }
387
+
388
+ function updateScrollBtn() {
389
+ if (autoScroll) {
390
+ scrollLockBtn.textContent = '⇊';
391
+ scrollLockBtn.className = '';
392
+ } else {
393
+ scrollLockBtn.textContent = '⏸';
394
+ scrollLockBtn.className = 'locked';
395
+ }
396
+ }
397
+
398
+ scrollLockBtn.onclick = (e) => {
399
+ e.preventDefault();
400
+ e.stopPropagation();
401
+ autoScroll = !autoScroll;
402
+ userScrolling = !autoScroll;
403
+ updateScrollBtn();
404
+ if (autoScroll && viewport) {
405
+ viewport.scrollTop = viewport.scrollHeight;
406
+ }
407
+ };
408
+
409
+ function showStreaming() {
410
+ streamingEl.style.display = 'flex';
411
+ clearTimeout(streamTimeout);
412
+ streamTimeout = setTimeout(() => {
413
+ streamingEl.style.display = 'none';
414
+ }, 500);
415
+ }
416
+
417
+ function connect() {
418
+ const proto = location.protocol==='https:'?'wss:':'ws:';
419
+ ws = new WebSocket(proto+'//'+location.host);
420
+
421
+ ws.onopen = () => {
422
+ statusEl.textContent = 'Connected';
423
+ statusEl.className = '';
424
+ reconnectBtn.style.display = 'none';
425
+ term.focus();
426
+ ws.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}));
427
+ };
428
+
429
+ ws.onmessage = e => {
430
+ showStreaming();
431
+ const scrollPos = viewport ? viewport.scrollTop : 0;
432
+ term.write(e.data);
433
+ requestAnimationFrame(() => {
434
+ if (viewport) {
435
+ if (autoScroll) {
436
+ viewport.scrollTop = viewport.scrollHeight;
437
+ } else {
438
+ viewport.scrollTop = scrollPos;
439
+ }
440
+ }
441
+ });
442
+ };
443
+
444
+ ws.onclose = () => {
445
+ statusEl.textContent = 'Disconnected';
446
+ statusEl.className = 'disconnected';
447
+ reconnectBtn.style.display = 'block';
448
+ streamingEl.style.display = 'none';
449
+ clearInterval(clientPing);
450
+ term.write('\\r\\n\\x1b[31m[Disconnected - auto-reconnecting...]\\x1b[0m\\r\\n');
451
+ setTimeout(connect, 3000);
452
+ };
453
+
454
+ ws.onerror = () => ws.close();
455
+
456
+ clientPing = setInterval(() => {
457
+ if (ws.readyState === 1) {
458
+ ws.send(JSON.stringify({type:'ping'}));
459
+ }
460
+ }, 20000);
461
+ }
462
+
463
+ let clientPing;
464
+
465
+ reconnectBtn.onclick = () => {
466
+ term.write('\\x1b[2J\\x1b[H\\x1b[32mReconnecting...\\x1b[0m\\r\\n');
467
+ connect();
468
+ };
469
+
470
+ stopBtn.onclick = () => {
471
+ if(ws && ws.readyState===1 && confirm('Stop Claude process?')) {
472
+ ws.send(JSON.stringify({type:'stop'}));
473
+ }
474
+ };
475
+
476
+ arrowDownBtn.onclick = () => {
477
+ if(ws && ws.readyState===1) {
478
+ ws.send(JSON.stringify({type:'input',data:'\\x1b[B'}));
479
+ }
480
+ };
481
+
482
+ enterBtn.onclick = () => {
483
+ if(ws && ws.readyState===1) {
484
+ ws.send(JSON.stringify({type:'input',data:'\\r'}));
485
+ }
486
+ };
487
+
488
+ escBtn.onclick = () => {
489
+ if(ws && ws.readyState===1) {
490
+ ws.send(JSON.stringify({type:'input',data:'\\x1b'}));
491
+ }
492
+ };
493
+
494
+ slashBtn.onclick = () => {
495
+ if(ws && ws.readyState===1) {
496
+ ws.send(JSON.stringify({type:'input',data:'/'}));
497
+ }
498
+ };
499
+
500
+ term.onData(data => {
501
+ if(ws && ws.readyState===1) ws.send(JSON.stringify({type:'input',data}));
502
+ });
503
+
504
+ window.addEventListener('resize',()=>{
505
+ fit.fit();
506
+ if(ws && ws.readyState===1) ws.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}));
507
+ });
508
+
509
+ connect();
510
+ updateScrollBtn();
511
+
512
+ const actionBtns = document.getElementById('action-btns');
513
+ if (window.visualViewport) {
514
+ const adjustForKeyboard = () => {
515
+ const offset = window.innerHeight - visualViewport.height;
516
+ actionBtns.style.bottom = (offset + 16) + 'px';
517
+ if (offset > 50 && viewport) {
518
+ viewport.scrollTop = viewport.scrollHeight;
519
+ }
520
+ };
521
+ window.visualViewport.addEventListener('resize', adjustForKeyboard);
522
+ window.visualViewport.addEventListener('scroll', adjustForKeyboard);
523
+ }
524
+ </script>
525
+ </body>
526
+ </html>`;
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "culater",
3
+ "version": "1.0.0",
4
+ "description": "Remote terminal for Claude Code - c(See) you later!",
5
+ "bin": {
6
+ "culater": "./bin/culater.js"
7
+ },
8
+ "scripts": {
9
+ "start": "node bin/culater.js"
10
+ },
11
+ "keywords": [
12
+ "claude",
13
+ "claude-code",
14
+ "remote",
15
+ "terminal",
16
+ "mobile",
17
+ "xterm",
18
+ "websocket",
19
+ "tunnel"
20
+ ],
21
+ "author": "Agam",
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "node-pty": "^1.0.0",
25
+ "ws": "^8.16.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/agam/culater"
33
+ }
34
+ }