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 +66 -0
- package/bin/culater.js +69 -0
- package/lib/server.js +526 -0
- package/package.json +34 -0
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
|
+
}
|