@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/src/client.js CHANGED
@@ -1,232 +1,89 @@
1
- import * as fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import { createHash, randomUUID } from 'node:crypto';
5
- import { execute, exists, quote, readJSON, restoreTerminal, shellJoin, which, withLock, writeJSON } from './system.js';
6
- import { ensureMutagen } from './installer.js';
7
- import { ensureRemoteClaude, remoteEnvironment as prefix } from './remote-claude.js';
8
- import { sshArgs } from './ssh.js';
9
- import { ensureSSH } from './ssh-auth.js';
10
- export { sshArgs } from './ssh.js';
11
-
12
- export const DEFAULTS = {
13
- root: '.local/share/claude-remote/projects', claude: 'claude',
14
- ignores: ['.DS_Store', 'node_modules', '.venv', '__pycache__']
15
- };
16
-
17
- export function validate(config) {
18
- if (config.mode != null && !['local', 'remote'].includes(config.mode)) throw new Error('模式只能是 local 或 remote。');
19
- if (config.browserHeadless != null && typeof config.browserHeadless !== 'boolean') throw new Error('browserHeadless 必须是布尔值。');
20
- if (!/^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(config.host || ''))
21
- throw new Error('服务器请填写 SSH 别名或 user@host,不要包含端口、空格或 SSH 参数。IPv6 请使用 SSH 别名。');
22
- if (config.port != null && (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535))
23
- throw new Error('端口必须在 1–65535 之间。');
24
- if (typeof config.root !== 'string' || !/^\/?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(config.root) ||
25
- config.root.split('/').some(part => ['.', '..'].includes(part)))
26
- throw new Error('远程根目录仅支持字母、数字、点、短横线、下划线和斜杠,不支持 ..。');
27
- if (typeof config.claude !== 'string' || !config.claude || /[\r\n\0]/.test(config.claude) || config.claude.startsWith('-'))
28
- throw new Error('远程 Claude 程序路径无效。');
29
- if (!Array.isArray(config.ignores) || config.ignores.some(pattern => typeof pattern !== 'string' || !pattern))
30
- throw new Error('忽略规则需要是非空字符串列表。');
31
- if (config.client_id != null && (typeof config.client_id !== 'string' || !config.client_id))
32
- throw new Error('配置文件中的 client_id 无效。');
33
- return config;
34
- }
35
-
36
- export const remotePath = file => file.startsWith('/') ? quote(file) : '"$HOME"/' + quote(file);
37
-
38
- export function assertSynchronized(current) {
39
- if (!current) throw new Error('同步会话不存在。');
40
- if (current.conflicts?.length || current.excludedConflicts)
41
- throw new Error('发现双向修改冲突,未自动覆盖。运行 claude-remote status 查看路径,备份后将两边文件统一,再运行 claude-remote flush。');
42
- for (const side of ['alpha', 'beta']) {
43
- const state = current[side] || {};
44
- if (state.scanProblems?.length || state.transitionProblems?.length || state.excludedScanProblems || state.excludedTransitionProblems)
45
- throw new Error('同步端点有文件处理错误,请运行 claude-remote status 查看详情。');
46
- if (state.connected === false) throw new Error('同步端点已断开,请检查连接后重试。');
47
- }
48
- if (current.lastError) throw new Error('同步尚有错误:' + current.lastError);
49
- if (current.paused) throw new Error('同步会话已暂停,请重新运行 claude 恢复。');
50
- }
51
-
52
- export class Client {
53
- constructor(options = {}) {
54
- this.env = options.env || process.env;
55
- this.home = options.home || os.homedir();
56
- this.cwd = options.cwd || process.cwd();
57
- this.platform = options.platform || process.platform;
58
- this.directory = options.configDir || this.env.CLAUDE_REMOTE_CONFIG_DIR || path.join(this.home, '.config', 'claude-remote');
59
- this.configFile = path.join(this.directory, 'config.json');
60
- this.registryFile = path.join(this.directory, 'projects.json');
61
- this.lockFile = path.join(this.directory, 'npm-state');
62
- this.execute = options.execute || execute;
63
- this.ensureSSH = options.ensureSSH || ensureSSH;
64
- this.ensureMutagen = options.ensureMutagen || ensureMutagen;
65
- this.log = options.log || console.error;
66
- this.warn = options.warn || console.error;
67
- this.debug = (...messages) => {
68
- if (this.env.CLAUDE_REMOTE_DEBUG === '1') this.log(...messages);
69
- };
70
- this.tty = options.tty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
71
- this.restoreTerminal = options.restoreTerminal || (() => restoreTerminal());
72
- }
73
-
74
- async configure(input) {
75
- return withLock(this.lockFile, async () => {
76
- const old = await readJSON(this.configFile, {});
77
- const config = validate({ ...DEFAULTS, ...input,
78
- mode: input.mode ?? old.mode ?? 'local', browserHeadless: input.browserHeadless ?? old.browserHeadless ?? false,
79
- ignores: [...DEFAULTS.ignores, ...(input.ignores || [])],
80
- client_id: old.client_id || randomUUID().replaceAll('-', '').slice(0, 12) });
81
- await writeJSON(this.configFile, config);
82
- this.log(`已保存:${config.host}(${config.mode === 'local' ? '本地执行' : '远程同步'})。`);
83
- this.debug(`配置文件:${this.configFile}`);
84
- if (old.host && (old.host !== config.host || (old.mode || 'remote') !== config.mode))
85
- this.log('停止旧同步:claude-remote stop --all');
86
- return config;
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
+ }
@@ -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 -f -- ' + remotePath(configFile) + ' ' + remotePath(promptFile), { timeout: 5000, check: false }).catch(() => {});
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) and the prompt (the rest), then report the version.
195
- // `read` consumes the pipe byte by byte, so nothing is lost between the two files.
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 -f -- ' + remotePath(configFile) + ' ' + remotePath(promptFile)) + ' EXIT; ' +
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();
@@ -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 project directory is created or synced.
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
  }