@znmin/claude-remote-cli 0.4.0 → 0.5.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 +62 -76
- package/package.json +3 -4
- package/scripts/postinstall.js +1 -11
- package/src/cli.js +26 -67
- package/src/client.js +89 -232
- package/src/local/clipboard.js +87 -0
- package/src/local/server.js +16 -1
- package/src/local-mode.js +20 -8
- package/src/remote-claude.js +1 -1
- package/src/remote-clipboard.js +97 -0
- package/src/ssh-auth.js +1 -1
- package/src/installer.js +0 -96
package/src/client.js
CHANGED
|
@@ -1,232 +1,89 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
throw new Error('
|
|
22
|
-
if (config.
|
|
23
|
-
throw new Error('
|
|
24
|
-
if (typeof config.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
this.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async settings() {
|
|
91
|
-
return withLock(this.lockFile, async () => {
|
|
92
|
-
const stored = await readJSON(this.configFile, {});
|
|
93
|
-
const config = { ...DEFAULTS, ...stored };
|
|
94
|
-
if (this.env.CLAUDE_REMOTE_HOST) config.host = this.env.CLAUDE_REMOTE_HOST;
|
|
95
|
-
if (this.env.CLAUDE_REMOTE_PORT !== undefined) config.port = Number(this.env.CLAUDE_REMOTE_PORT);
|
|
96
|
-
config.mode = this.env.CLAUDE_REMOTE_MODE || config.mode || 'remote';
|
|
97
|
-
if (!config.host) throw new Error('尚未配置服务器。请运行:claude-remote configure 用户名@服务器(自定义端口加 --port 2222)。');
|
|
98
|
-
validate(config);
|
|
99
|
-
if (!config.client_id) {
|
|
100
|
-
config.client_id = randomUUID().replaceAll('-', '').slice(0, 12);
|
|
101
|
-
// Temporary environment overrides must not change the saved server.
|
|
102
|
-
await writeJSON(this.configFile, { ...stored, client_id: config.client_id });
|
|
103
|
-
}
|
|
104
|
-
return config;
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async project(config) {
|
|
109
|
-
const local = await fs.realpath(this.cwd);
|
|
110
|
-
const home = await fs.realpath(this.home);
|
|
111
|
-
if (local === home || local === path.parse(local).root)
|
|
112
|
-
throw new Error('请进入具体项目目录;不允许同步整个用户目录或磁盘根目录。');
|
|
113
|
-
const git = path.join(local, '.git');
|
|
114
|
-
if (await exists(git) && (await fs.stat(git)).isFile())
|
|
115
|
-
throw new Error('当前目录的 .git 指向外部仓库,暂不支持 Git worktree/子模块;请使用普通独立仓库。');
|
|
116
|
-
// Keep the original Python implementation's stable project identifiers.
|
|
117
|
-
const identity = '[' + [config.client_id, config.host, config.port ?? null, config.root, local]
|
|
118
|
-
.map(value => JSON.stringify(value)).join(', ') + ']';
|
|
119
|
-
const key = createHash('sha256').update(identity).digest('hex').slice(0, 24);
|
|
120
|
-
return { name: 'claude-remote-' + key, local, remote: config.root + '/' + key,
|
|
121
|
-
host: config.host, port: config.port ?? null };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async setMode(mode) {
|
|
125
|
-
if (!['local', 'remote'].includes(mode)) throw new Error('模式只能是 local 或 remote。');
|
|
126
|
-
await withLock(this.lockFile, async () => {
|
|
127
|
-
const stored = await readJSON(this.configFile, {});
|
|
128
|
-
await writeJSON(this.configFile, { ...stored, mode });
|
|
129
|
-
});
|
|
130
|
-
this.log(`默认模式已设为 ${mode}。旧后台同步不会自动停止;如需停止请运行 claude-remote stop --all。`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async prepare() {
|
|
134
|
-
if (!this.mutagen) this.mutagen = await this.ensureMutagen({ env: this.env });
|
|
135
|
-
return this.mutagen;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async sync(args, options = {}) {
|
|
139
|
-
const binary = await this.prepare();
|
|
140
|
-
return this.execute(binary, ['sync', ...args], { env: this.env, capture: true, ...options });
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async sessions() {
|
|
144
|
-
const result = await this.sync(['list', '--label-selector', 'claude-remote=true', '--template', '{{json .}}'],
|
|
145
|
-
{ capture: true, timeout: 30000 });
|
|
146
|
-
return JSON.parse(result.stdout) || [];
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async flush(name) {
|
|
150
|
-
await this.sync(['flush', name], { timeout: 120000 });
|
|
151
|
-
assertSynchronized((await this.sessions()).find(session => session.name === name));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async remote(config, script, interactive = false, { timeout = 180000 } = {}) {
|
|
155
|
-
return this.execute('ssh', sshArgs(config, script, { interactive, tty: interactive && this.tty }),
|
|
156
|
-
{ env: this.env, timeout: interactive ? 0 : timeout, check: !interactive });
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async launch(config, extra) {
|
|
160
|
-
this.debug(`连接:${config.host}${config.port ? ':' + config.port : ''},模式:${config.mode || 'remote'}`);
|
|
161
|
-
await this.ensureSSH(this, config);
|
|
162
|
-
if (config.mode === 'local') {
|
|
163
|
-
const { launchLocal } = await import('./local-mode.js');
|
|
164
|
-
return launchLocal(this, config, extra);
|
|
165
|
-
}
|
|
166
|
-
for (const tool of ['ssh', 'scp']) if (!await which(tool, this.env.PATH))
|
|
167
|
-
throw new Error(`找不到系统 ${tool},请安装 OpenSSH 客户端。`);
|
|
168
|
-
const item = await this.project(config);
|
|
169
|
-
await this.prepare();
|
|
170
|
-
await withLock(this.lockFile, async () => {
|
|
171
|
-
const existing = await this.sessions();
|
|
172
|
-
const registry = await readJSON(this.registryFile, {});
|
|
173
|
-
for (const session of existing) {
|
|
174
|
-
if (session.name === item.name) continue;
|
|
175
|
-
const storedPath = session.alpha?.path || registry[session.name]?.local;
|
|
176
|
-
if (!storedPath) continue;
|
|
177
|
-
let other;
|
|
178
|
-
try { other = await fs.realpath(storedPath); }
|
|
179
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; other = path.resolve(storedPath); }
|
|
180
|
-
const relative = path.relative(other, item.local);
|
|
181
|
-
const reverse = path.relative(item.local, other);
|
|
182
|
-
const inside = value => value === '' || (!value.startsWith('..' + path.sep) && value !== '..' && !path.isAbsolute(value));
|
|
183
|
-
if (inside(relative) || inside(reverse))
|
|
184
|
-
throw new Error(`目录与正在同步的项目重叠:${other}\n先运行:claude-remote stop --name ${session.name}`);
|
|
185
|
-
}
|
|
186
|
-
await this.remote(config, ensureRemoteClaude(config.claude) +
|
|
187
|
-
'mkdir -p -- ' + remotePath(item.remote), false, { timeout: 600000 });
|
|
188
|
-
if (existing.some(session => session.name === item.name)) {
|
|
189
|
-
await this.sync(['resume', item.name], { timeout: 60000, capture: this.env.CLAUDE_REMOTE_DEBUG !== '1' || !this.tty });
|
|
190
|
-
} else {
|
|
191
|
-
const endpoint = config.host + ':' + (config.port != null ? config.port + ':' : '') +
|
|
192
|
-
(item.remote.startsWith('/') ? item.remote : '~/' + item.remote);
|
|
193
|
-
const args = ['create', '--name', item.name, '--label', 'claude-remote=true', '--mode', 'two-way-safe',
|
|
194
|
-
'--no-global-configuration', '--no-ignore-vcs', '--watch-polling-interval', '2'];
|
|
195
|
-
for (const pattern of config.ignores) args.push('--ignore', pattern);
|
|
196
|
-
await this.sync([...args, item.local, endpoint], { timeout: 180000, capture: this.env.CLAUDE_REMOTE_DEBUG !== '1' || !this.tty });
|
|
197
|
-
}
|
|
198
|
-
registry[item.name] = item;
|
|
199
|
-
await writeJSON(this.registryFile, registry);
|
|
200
|
-
});
|
|
201
|
-
this.debug(`本地:${item.local}\n远程:${config.host}:${item.remote}`);
|
|
202
|
-
this.log('正在同步…');
|
|
203
|
-
await this.flush(item.name);
|
|
204
|
-
this.debug('同步完成,启动远程 Claude Code。退出后继续后台同步;停止请运行 claude-remote stop。');
|
|
205
|
-
let code = 1;
|
|
206
|
-
try {
|
|
207
|
-
code = (await this.remote(config, prefix + 'cd -- ' + remotePath(item.remote) +
|
|
208
|
-
' && exec ' + shellJoin([config.claude, ...extra]), true)).code;
|
|
209
|
-
// A dropped connection leaves the remote UI's terminal modes switched on.
|
|
210
|
-
if (code !== 0) this.restoreTerminal();
|
|
211
|
-
} finally {
|
|
212
|
-
try { await this.flush(item.name); }
|
|
213
|
-
catch (error) {
|
|
214
|
-
this.warn(`退出时同步未确认完成:${error.message}\n会话保留,可用 claude-remote status 检查。`);
|
|
215
|
-
if (code === 0) code = 1;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return code;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async stop({ all = false, name } = {}) {
|
|
222
|
-
const selected = all || name ? name : (await this.project(await this.settings())).name;
|
|
223
|
-
return withLock(this.lockFile, async () => {
|
|
224
|
-
const available = (await this.sessions()).map(session => session.name);
|
|
225
|
-
const targets = all ? available : [selected];
|
|
226
|
-
if (!targets.length || targets.some(target => !available.includes(target)))
|
|
227
|
-
throw new Error('没有找到对应同步会话;运行 claude-remote status 查看。');
|
|
228
|
-
await this.sync(['terminate', ...targets], { timeout: 30000 });
|
|
229
|
-
this.log('已停止同步,保留本地和远程文件。');
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
}
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { quote, readJSON, restoreTerminal, withLock, writeJSON, execute } from './system.js';
|
|
5
|
+
import { ensureSSH } from './ssh-auth.js';
|
|
6
|
+
export { sshArgs } from './ssh.js';
|
|
7
|
+
|
|
8
|
+
export const DEFAULTS = { root: '.local/share/claude-remote/projects', claude: 'claude' };
|
|
9
|
+
|
|
10
|
+
// Saved by versions before 0.5.0 (server mode with file synchronization); ignored now.
|
|
11
|
+
const OBSOLETE = ['mode', 'ignores'];
|
|
12
|
+
|
|
13
|
+
export function validate(config) {
|
|
14
|
+
if (config.browserHeadless != null && typeof config.browserHeadless !== 'boolean') throw new Error('browserHeadless 必须是布尔值。');
|
|
15
|
+
if (!/^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(config.host || ''))
|
|
16
|
+
throw new Error('服务器请填写 SSH 别名或 user@host,不要包含端口、空格或 SSH 参数。IPv6 请使用 SSH 别名。');
|
|
17
|
+
if (config.port != null && (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535))
|
|
18
|
+
throw new Error('端口必须在 1–65535 之间。');
|
|
19
|
+
if (typeof config.root !== 'string' || !/^\/?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(config.root) ||
|
|
20
|
+
config.root.split('/').some(part => ['.', '..'].includes(part)))
|
|
21
|
+
throw new Error('远程根目录仅支持字母、数字、点、短横线、下划线和斜杠,不支持 ..。');
|
|
22
|
+
if (typeof config.claude !== 'string' || !config.claude || /[\r\n\0]/.test(config.claude) || config.claude.startsWith('-'))
|
|
23
|
+
throw new Error('远程 Claude 程序路径无效。');
|
|
24
|
+
if (config.client_id != null && (typeof config.client_id !== 'string' || !config.client_id))
|
|
25
|
+
throw new Error('配置文件中的 client_id 无效。');
|
|
26
|
+
return config;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const remotePath = file => file.startsWith('/') ? quote(file) : '"$HOME"/' + quote(file);
|
|
30
|
+
|
|
31
|
+
const withoutObsolete = config => Object.fromEntries(Object.entries(config).filter(([key]) => !OBSOLETE.includes(key)));
|
|
32
|
+
|
|
33
|
+
export class Client {
|
|
34
|
+
constructor(options = {}) {
|
|
35
|
+
this.env = options.env || process.env;
|
|
36
|
+
this.home = options.home || os.homedir();
|
|
37
|
+
this.cwd = options.cwd || process.cwd();
|
|
38
|
+
this.platform = options.platform || process.platform;
|
|
39
|
+
this.directory = options.configDir || this.env.CLAUDE_REMOTE_CONFIG_DIR || path.join(this.home, '.config', 'claude-remote');
|
|
40
|
+
this.configFile = path.join(this.directory, 'config.json');
|
|
41
|
+
this.lockFile = path.join(this.directory, 'npm-state');
|
|
42
|
+
this.execute = options.execute || execute;
|
|
43
|
+
this.ensureSSH = options.ensureSSH || ensureSSH;
|
|
44
|
+
this.log = options.log || console.error;
|
|
45
|
+
this.warn = options.warn || console.error;
|
|
46
|
+
this.debug = (...messages) => {
|
|
47
|
+
if (this.env.CLAUDE_REMOTE_DEBUG === '1') this.log(...messages);
|
|
48
|
+
};
|
|
49
|
+
this.tty = options.tty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
50
|
+
this.restoreTerminal = options.restoreTerminal || (() => restoreTerminal());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async configure(input) {
|
|
54
|
+
return withLock(this.lockFile, async () => {
|
|
55
|
+
const old = await readJSON(this.configFile, {});
|
|
56
|
+
const config = validate({ ...DEFAULTS, ...withoutObsolete(input),
|
|
57
|
+
browserHeadless: input.browserHeadless ?? old.browserHeadless ?? false,
|
|
58
|
+
client_id: old.client_id || randomUUID().replaceAll('-', '').slice(0, 12) });
|
|
59
|
+
await writeJSON(this.configFile, config);
|
|
60
|
+
this.log(`已保存:${config.host}。`);
|
|
61
|
+
this.debug(`配置文件:${this.configFile}`);
|
|
62
|
+
return config;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async settings() {
|
|
67
|
+
return withLock(this.lockFile, async () => {
|
|
68
|
+
const stored = await readJSON(this.configFile, {});
|
|
69
|
+
const config = { ...DEFAULTS, ...withoutObsolete(stored) };
|
|
70
|
+
if (this.env.CLAUDE_REMOTE_HOST) config.host = this.env.CLAUDE_REMOTE_HOST;
|
|
71
|
+
if (this.env.CLAUDE_REMOTE_PORT !== undefined) config.port = Number(this.env.CLAUDE_REMOTE_PORT);
|
|
72
|
+
if (!config.host) throw new Error('尚未配置服务器。请运行:claude-remote configure 用户名@服务器(自定义端口加 --port 2222)。');
|
|
73
|
+
validate(config);
|
|
74
|
+
if (!config.client_id) {
|
|
75
|
+
config.client_id = randomUUID().replaceAll('-', '').slice(0, 12);
|
|
76
|
+
// Temporary environment overrides must not change the saved server.
|
|
77
|
+
await writeJSON(this.configFile, { ...stored, client_id: config.client_id });
|
|
78
|
+
}
|
|
79
|
+
return config;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async launch(config, extra) {
|
|
84
|
+
this.debug(`连接:${config.host}${config.port ? ':' + config.port : ''}`);
|
|
85
|
+
await this.ensureSSH(this, config);
|
|
86
|
+
const { launchLocal } = await import('./local-mode.js');
|
|
87
|
+
return launchLocal(this, config, extra);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const TIMEOUT = 15000;
|
|
7
|
+
const MAX_IMAGE = 64 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
// Windows: a bitmap on the clipboard (screenshots), or an image file copied in
|
|
10
|
+
// Explorer. Both are re-encoded as PNG and written to stdout as base64.
|
|
11
|
+
const WINDOWS_SCRIPT = `
|
|
12
|
+
$ErrorActionPreference = 'Stop'
|
|
13
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
14
|
+
Add-Type -AssemblyName System.Drawing
|
|
15
|
+
$image = [System.Windows.Forms.Clipboard]::GetImage()
|
|
16
|
+
if ($null -eq $image -and [System.Windows.Forms.Clipboard]::ContainsFileDropList()) {
|
|
17
|
+
foreach ($file in [System.Windows.Forms.Clipboard]::GetFileDropList()) {
|
|
18
|
+
if ($file -match '\\.(png|jpe?g|gif|bmp|tiff?)$') {
|
|
19
|
+
try { $image = [System.Drawing.Image]::FromFile($file); break } catch { }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if ($null -eq $image) { exit 3 }
|
|
24
|
+
$stream = New-Object System.IO.MemoryStream
|
|
25
|
+
$image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
26
|
+
[Console]::Out.Write([Convert]::ToBase64String($stream.ToArray()))
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
// Runs a program and collects stdout as bytes. Resolves null on any failure:
|
|
30
|
+
// an empty clipboard and a missing tool both just mean "no image".
|
|
31
|
+
export function captureBytes(command, args, { timeout = TIMEOUT, env = process.env } = {}) {
|
|
32
|
+
return new Promise(resolve => {
|
|
33
|
+
let child;
|
|
34
|
+
try { child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); }
|
|
35
|
+
catch { resolve(null); return; }
|
|
36
|
+
const chunks = []; let bytes = 0, failed = false;
|
|
37
|
+
const timer = setTimeout(() => { failed = true; child.kill(); }, timeout);
|
|
38
|
+
child.stdout.on('data', chunk => {
|
|
39
|
+
bytes += chunk.length;
|
|
40
|
+
if (bytes > MAX_IMAGE * 2) { failed = true; child.kill(); } else chunks.push(chunk);
|
|
41
|
+
});
|
|
42
|
+
child.once('error', () => { clearTimeout(timer); resolve(null); });
|
|
43
|
+
child.once('close', code => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
resolve(failed || code !== 0 ? null : Buffer.concat(chunks));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isPng = data => data?.length > 8 && data.readUInt32BE(0) === 0x89504e47;
|
|
51
|
+
|
|
52
|
+
async function readWindows(run, env) {
|
|
53
|
+
const system = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows';
|
|
54
|
+
const powershell = path.join(system, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
55
|
+
const output = await run(powershell, ['-NoProfile', '-NonInteractive', '-Sta', '-ExecutionPolicy', 'Bypass',
|
|
56
|
+
'-Command', WINDOWS_SCRIPT], { env });
|
|
57
|
+
if (!output?.length) return null;
|
|
58
|
+
return Buffer.from(output.toString('ascii').trim(), 'base64');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readMac(run, env) {
|
|
62
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ccr-clip-'));
|
|
63
|
+
const file = path.join(directory, 'clipboard.png');
|
|
64
|
+
try {
|
|
65
|
+
const result = await run('osascript', ['-e', 'set png_data to (the clipboard as «class PNGf»)',
|
|
66
|
+
'-e', `set fp to open for access POSIX file ${JSON.stringify(file)} with write permission`,
|
|
67
|
+
'-e', 'write png_data to fp', '-e', 'close access fp'], { env });
|
|
68
|
+
if (result === null) return null;
|
|
69
|
+
return await fs.readFile(file);
|
|
70
|
+
} catch { return null; }
|
|
71
|
+
finally { await fs.rm(directory, { recursive: true, force: true }); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readLinux(run, env) {
|
|
75
|
+
if (env.WAYLAND_DISPLAY) {
|
|
76
|
+
const data = await run('wl-paste', ['--no-newline', '--type', 'image/png'], { env });
|
|
77
|
+
if (data?.length) return data;
|
|
78
|
+
}
|
|
79
|
+
return run('xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o'], { env });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Returns the image on THIS computer's clipboard as PNG bytes, or null.
|
|
83
|
+
export async function readClipboardImage({ platform = process.platform, env = process.env, run = captureBytes } = {}) {
|
|
84
|
+
const read = platform === 'win32' ? readWindows : platform === 'darwin' ? readMac : readLinux;
|
|
85
|
+
const data = await read(run, env).catch(() => null);
|
|
86
|
+
return isPng(data) && data.length <= MAX_IMAGE ? data : null;
|
|
87
|
+
}
|
package/src/local/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { Processes } from './processes.js';
|
|
|
11
11
|
import { describeShell } from './shell.js';
|
|
12
12
|
import { Browser } from './browser.js';
|
|
13
13
|
import { MAX_RESULT } from './mcp-proxy.js';
|
|
14
|
+
import { readClipboardImage } from './clipboard.js';
|
|
14
15
|
|
|
15
16
|
const PACKAGE = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
16
17
|
|
|
@@ -44,7 +45,7 @@ const selector = z.string().min(1).max(4000);
|
|
|
44
45
|
// `upstreams` maps names to developer MCP servers (see mcp-proxy.js), served at
|
|
45
46
|
// /mcp/<name>. The map may be filled after the gateway starts.
|
|
46
47
|
export async function startLocalServer({ root, env = process.env, headless = false, browser, log = console.error,
|
|
47
|
-
upstreams = new Map() } = {}) {
|
|
48
|
+
upstreams = new Map(), readClipboard = readClipboardImage } = {}) {
|
|
48
49
|
const workspace = new Workspace(await fs.realpath(root));
|
|
49
50
|
const processes = new Processes(workspace, env);
|
|
50
51
|
browser ||= new Browser({ headless, log, env });
|
|
@@ -149,6 +150,20 @@ export async function startLocalServer({ root, env = process.env, headless = fal
|
|
|
149
150
|
if (closing) { response.writeHead(503).end(); return; }
|
|
150
151
|
// Browser pages cannot invoke this privileged endpoint; native MCP clients do not send Origin.
|
|
151
152
|
if (request.headers.origin) { response.writeHead(403).end('Origin not allowed'); return; }
|
|
153
|
+
// The remote `xclip` stand-in fetches the LOCAL clipboard image here, so
|
|
154
|
+
// pasting a screenshot (Alt+V / Ctrl+V) in the remote Claude works.
|
|
155
|
+
if (request.url === '/clipboard/image') {
|
|
156
|
+
if (request.method !== 'GET') { response.writeHead(405, { Allow: 'GET' }).end(); return; }
|
|
157
|
+
try {
|
|
158
|
+
const image = await readClipboard({ env });
|
|
159
|
+
if (!image) { response.writeHead(204).end(); return; }
|
|
160
|
+
response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': image.length }).end(image);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
log('读取本机剪贴板失败:' + error.message);
|
|
163
|
+
if (!response.headersSent) response.writeHead(500).end();
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
152
167
|
let upstream;
|
|
153
168
|
if (request.url !== '/mcp') {
|
|
154
169
|
const match = request.url.match(/^\/mcp\/([A-Za-z0-9_-]{1,64})$/);
|
package/src/local-mode.js
CHANGED
|
@@ -5,6 +5,7 @@ import { onCleanup, quote, shellJoin, which } from './system.js';
|
|
|
5
5
|
import { sshArgs } from './ssh.js';
|
|
6
6
|
import { remotePath } from './client.js';
|
|
7
7
|
import { ensureRemoteClaude, remoteEnvironment } from './remote-claude.js';
|
|
8
|
+
import { ALT_V_BINDING, ensureAltVBinding, installClipboardShim, KEYBINDING_MISSING } from './remote-clipboard.js';
|
|
8
9
|
import { startLocalServer } from './local/server.js';
|
|
9
10
|
import { installBrowser } from './local/browser.js';
|
|
10
11
|
import { openTunnel } from './local/tunnel.js';
|
|
@@ -151,7 +152,7 @@ export async function launchLocal(client, config, extra, dependencies = {}) {
|
|
|
151
152
|
// Prepare the browser while SSH connects; it is only needed before Claude starts.
|
|
152
153
|
const browserReady = (dependencies.installBrowser || installBrowser)(client.log, { debug: client.debug, env: client.env });
|
|
153
154
|
browserReady.catch(() => {}); // Reported when awaited below.
|
|
154
|
-
let gateway, tunnel, configFile, promptFile, cleaning;
|
|
155
|
+
let gateway, tunnel, configFile, promptFile, clipboardDirectory, cleaning;
|
|
155
156
|
const upstreams = new Map(), abort = new AbortController();
|
|
156
157
|
let connecting = Promise.resolve();
|
|
157
158
|
const hash = createHash('sha256').update(config.client_id + '\0' + root).digest('hex').slice(0, 24);
|
|
@@ -162,7 +163,8 @@ export async function launchLocal(client, config, extra, dependencies = {}) {
|
|
|
162
163
|
timeout: interactive ? 0 : timeout, check });
|
|
163
164
|
const cleanup = () => cleaning ||= (async () => {
|
|
164
165
|
if (tunnel && configFile)
|
|
165
|
-
await remote('rm -
|
|
166
|
+
await remote('rm -rf -- ' + remotePath(configFile) + ' ' + remotePath(promptFile) + ' ' + remotePath(clipboardDirectory),
|
|
167
|
+
{ timeout: 5000, check: false }).catch(() => {});
|
|
166
168
|
abort.abort();
|
|
167
169
|
await gateway?.close();
|
|
168
170
|
await connecting;
|
|
@@ -177,11 +179,13 @@ export async function launchLocal(client, config, extra, dependencies = {}) {
|
|
|
177
179
|
.then(connected => { for (const [name, upstream] of connected) upstreams.set(name, upstream); })
|
|
178
180
|
.catch(error => client.warn('MCP 连接失败:' + error.message));
|
|
179
181
|
gateway = await (dependencies.startLocalServer || startLocalServer)({ root, env: client.env, upstreams,
|
|
182
|
+
readClipboard: dependencies.readClipboard,
|
|
180
183
|
headless: client.env.CLAUDE_REMOTE_HEADLESS === undefined ? Boolean(config.browserHeadless) : client.env.CLAUDE_REMOTE_HEADLESS === '1', log: client.log });
|
|
181
184
|
tunnel = await (dependencies.openTunnel || openTunnel)(config, gateway.port, { env: client.env, run: client.execute });
|
|
182
185
|
const session = randomUUID();
|
|
183
186
|
configFile = remoteDirectory + '/.local-mcp-' + session + '.json';
|
|
184
187
|
promptFile = remoteDirectory + '/.local-prompt-' + session + '.md';
|
|
188
|
+
clipboardDirectory = remoteDirectory + '/.local-clipboard-' + session;
|
|
185
189
|
await connecting;
|
|
186
190
|
const endpoint = suffix => ({ type: 'http', url: `http://127.0.0.1:${tunnel.port}/mcp${suffix}`,
|
|
187
191
|
headers: { Authorization: 'Bearer ' + gateway.token }, timeout: CALL_TIMEOUT });
|
|
@@ -191,22 +195,30 @@ export async function launchLocal(client, config, extra, dependencies = {}) {
|
|
|
191
195
|
const prompt = localPrompt(root, { instructions: await projectInstructions(root), appended: extractAppendedPrompts(extra).prompts,
|
|
192
196
|
platform: client.platform, mcpServers: [...upstreams.keys()] });
|
|
193
197
|
// One round trip: install Claude if missing, store the token-bearing MCP
|
|
194
|
-
// config (first stdin line)
|
|
195
|
-
//
|
|
198
|
+
// config (first stdin line), the clipboard stand-in with its Authorization
|
|
199
|
+
// header (second line) and the prompt (the rest), then report the version.
|
|
200
|
+
// `read` consumes the pipe byte by byte, so nothing is lost between the files.
|
|
196
201
|
const version = await remote(ensureRemoteClaude(config.claude) +
|
|
197
202
|
'umask 077; mkdir -p -- ' + remotePath(remoteDirectory) + '\n' +
|
|
198
203
|
'IFS= read -r line; printf \'%s\\n\' "$line" > ' + remotePath(configFile) + '\n' +
|
|
204
|
+
installClipboardShim(remotePath(clipboardDirectory), quote(`http://127.0.0.1:${tunnel.port}/clipboard/image`)) +
|
|
205
|
+
(client.platform === 'win32' ? ensureAltVBinding() : '') +
|
|
199
206
|
'cat > ' + remotePath(promptFile) + '\n' +
|
|
200
207
|
shellJoin([config.claude, '--version']) + ' </dev/null',
|
|
201
|
-
{ input: JSON.stringify(mcp) + '\n' + prompt, timeout: 600000 });
|
|
202
|
-
if (!supportsLocalMode(version.stdout)) throw new Error('本地工具模式需要服务器 Claude Code 2.1.248 或更高版本。请先在服务器运行 claude update。');
|
|
208
|
+
{ input: JSON.stringify(mcp) + '\nAuthorization: Bearer ' + gateway.token + '\n' + prompt, timeout: 600000 });
|
|
209
|
+
if (!supportsLocalMode(version.stdout)) throw new Error('本地工具模式需要服务器 Claude Code 2.1.248 或更高版本。请先在服务器运行 claude update。');
|
|
210
|
+
if (version.stdout.includes(KEYBINDING_MISSING))
|
|
211
|
+
client.warn('服务器的 ~/.claude/keybindings.json 已存在但未绑定 Alt+V,按 Alt+V 无法粘贴本机截图。' +
|
|
212
|
+
'请在其 "bindings" 数组中加入:' + JSON.stringify(ALT_V_BINDING));
|
|
203
213
|
await browserReady;
|
|
204
214
|
client.debug(`本地执行模式:${root}\n文件、命令和浏览器在本机运行;项目文件不通过本模式同步。`);
|
|
205
215
|
const args = localClaudeArguments(path.posix.basename(configFile), path.posix.basename(promptFile), extra);
|
|
206
216
|
// Keep the parent shell so EXIT can remove the token-bearing temporary files.
|
|
217
|
+
// The clipboard stand-in goes first on PATH for Claude only, so pasting an
|
|
218
|
+
// image reads this computer's clipboard.
|
|
207
219
|
const script = remoteEnvironment + 'cd -- ' + remotePath(remoteDirectory) + ' || exit 1; ' +
|
|
208
|
-
'trap ' + quote('rm -
|
|
209
|
-
shellJoin([config.claude, ...args]);
|
|
220
|
+
'trap ' + quote('rm -rf -- ' + remotePath(configFile) + ' ' + remotePath(promptFile) + ' ' + remotePath(clipboardDirectory)) + ' EXIT; ' +
|
|
221
|
+
'PATH=' + remotePath(clipboardDirectory) + ':"$PATH" ' + shellJoin([config.claude, ...args]);
|
|
210
222
|
const code = (await remote(script, { interactive: true, check: false })).code;
|
|
211
223
|
// A dropped connection leaves the remote UI's terminal modes switched on.
|
|
212
224
|
if (code !== 0) client.restoreTerminal();
|
package/src/remote-claude.js
CHANGED
|
@@ -33,7 +33,7 @@ export function ensureRemoteClaude(command) {
|
|
|
33
33
|
echo ${quote('找不到配置的 Claude 程序:' + command + '。请修正 --claude 路径,或配置 --claude claude 使用自动安装。')} >&2
|
|
34
34
|
exit 127
|
|
35
35
|
`;
|
|
36
|
-
// A failed download/installer must stop before any
|
|
36
|
+
// A failed download/installer must stop before any session files are written.
|
|
37
37
|
return 'set -e\n' + remoteEnvironment + '\n' +
|
|
38
38
|
`if ! command -v ${quote(command)} >/dev/null 2>&1; then\n${missing}fi\n`;
|
|
39
39
|
}
|