@znmin/claude-remote-cli 0.4.0 → 0.4.1
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 +14 -3
- package/package.json +1 -1
- package/src/local/clipboard.js +87 -0
- package/src/local/server.js +16 -1
- package/src/local-mode.js +20 -8
- package/src/remote-clipboard.js +97 -0
package/README.md
CHANGED
|
@@ -25,10 +25,10 @@ npm install -g git+https://github.com/znmin/claude-remote-cli.git
|
|
|
25
25
|
也可以从已下载的分发包安装:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
npm install -g ./znmin-claude-remote-cli-0.4.
|
|
28
|
+
npm install -g ./znmin-claude-remote-cli-0.4.1.tgz
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.4.
|
|
31
|
+
若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.4.1.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
|
|
32
32
|
|
|
33
33
|
npm 中不带作用域的 `claude-remote-cli` 属于其他项目。本项目使用独立的 `@znmin/claude-remote-cli` 作用域包名;请安装完整包名,或使用上述 GitHub 地址及本项目分发的 `.tgz` 文件。
|
|
34
34
|
|
|
@@ -175,6 +175,17 @@ Linux 上仍需安装 Playwright 所需的系统共享库,有窗口模式还
|
|
|
175
175
|
|
|
176
176
|
启动时使用 `--restricted`、`--tools "WebSearch,WebFetch"`、`--strict-mcp-config`,限定工具和设置来源、关闭普通 hooks。0.2.2 起恢复 `/config`、`/model`、`/help` 等内置交互命令,不再使用禁用整个命令菜单的 `--disable-slash-commands`;通过 `disableSkillShellExecution` 和 `disableBundledSkills` 单独关闭技能内联 shell 和内置技能工作流。恢复菜单不代表所有依赖服务器项目目录或原生工具的命令都适用于本地模式。设置面板修改的是服务器上的 Claude 配置;受限模式仍只加载托管配置和本次 `--settings`,不会恢复读取普通用户/项目 settings.json,因此偏好设置的跨会话持久化仍受该限制影响,可用 `claude --model sonnet` 明确指定启动模型。保留服务器上的订阅认证,不使用跳过 OAuth 的 `--bare`。本地连接失效时不会将文件或命令操作退回服务器执行。支持 `--continue`、`--resume`、`--model`、`--effort`、`--name`、`--fallback-model`、`-p` 和输入/输出格式;`--permission-mode` 支持 `default`、`manual`、`acceptEdits`、`plan`、`auto`、`dontAsk`(受限模式本身拒绝 `bypassPermissions`)。暂不接受替换工具配置、加载插件、切换云会话等参数。
|
|
177
177
|
|
|
178
|
+
#### 粘贴本机截图(0.4.1)
|
|
179
|
+
|
|
180
|
+
本地模式下,截图后直接在 Claude 输入框按平时的图片粘贴快捷键(Windows 为 Alt+V,macOS/Linux 为 Ctrl+V),就能粘贴**本机**剪贴板里的图片,和在本机运行 Claude Code 一样显示为 `[Image #1]`。
|
|
181
|
+
|
|
182
|
+
原理:Linux 版 Claude Code 通过 `xclip -selection clipboard -t TARGETS/image/png -o` 读取剪贴板图片。每个会话在服务器临时目录放一个 `xclip` 替身,只放在本次 Claude 进程的 PATH 最前面。替身通过同一条 SSH 反向隧道向本机工具服务请求剪贴板图片(`GET /clipboard/image`,校验同一个令牌);令牌以 0600 文件保存,通过 `curl -H @文件` 读取,不出现在服务器进程列表里。其他 `xclip` 调用(读取文本、其他选区)交给服务器上原有的 `xclip`,只有在 Claude 请求图片时才会读取本机剪贴板。退出时替身和令牌文件随临时配置一起删除。
|
|
183
|
+
|
|
184
|
+
- 快捷键:服务器上的 Linux 版 Claude 默认只认 Ctrl+V,而 Windows 终端会自己处理 Ctrl+V。因此从 Windows 启动时,如果服务器上没有 `~/.claude/keybindings.json`,会自动创建它并加入 `"alt+v": "chat:imagePaste"`(`--restricted` 下同样生效)。已有该文件时不会修改,只在启动时提示需要加入的那一行。
|
|
185
|
+
- 本机读取方式:Windows 用 PowerShell 读取截图或资源管理器中复制的图片文件;macOS 用 `osascript`;Linux 用 `wl-paste` 或 `xclip`。
|
|
186
|
+
- 服务器需要 `curl` 或 `python3`。
|
|
187
|
+
- 服务器模式(`remote`)暂不支持;需要时可将图片保存到项目目录,同步后用 `@文件名` 引用。
|
|
188
|
+
|
|
178
189
|
本地模式的路由规则、本地 CLAUDE.md 和用户传入的 `--append-system-prompt` 内容合并为一个文件,通过 SSH 标准输入写入服务器临时目录(权限 0600),再用 `--append-system-prompt-file` 加载;用户的追加内容排在路由规则之后,不会替换它。退出时与 MCP 配置一起删除。
|
|
179
190
|
|
|
180
191
|
这是工具路由约束,不是对 Claude Code 主进程的系统隔离。认证、会话和日志仍在服务器,管理员管理策略继续生效。文件内容、命令结果和截图会经过服务器交给模型,不整体同步不代表数据不离开本机。
|
|
@@ -296,7 +307,7 @@ npm run test:windows # Windows 上运行的客户端测试(CI 在 windows-la
|
|
|
296
307
|
CLAUDE_REMOTE_INTEGRATION=1 CLAUDE_REMOTE_BROWSER_TEST=1 npm test
|
|
297
308
|
|
|
298
309
|
npm pack
|
|
299
|
-
npm install -g ./znmin-claude-remote-cli-0.4.
|
|
310
|
+
npm install -g ./znmin-claude-remote-cli-0.4.1.tgz
|
|
300
311
|
|
|
301
312
|
# 确定包名、版本和使用条款后,使用有发布权限的 npm 账号发布
|
|
302
313
|
npm login
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znmin/claude-remote-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Run Claude Code on an SSH server from macOS, Linux or Windows with local filesystem, terminal and browser tools, or remote bidirectional sync",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -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();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { quote } from './system.js';
|
|
2
|
+
|
|
3
|
+
// Claude Code on Linux reads a pasted image by running
|
|
4
|
+
// xclip -selection clipboard -t TARGETS -o (is there an image?)
|
|
5
|
+
// xclip -selection clipboard -t image/png -o (write it to a file)
|
|
6
|
+
// This stand-in is put first on the remote Claude's PATH and answers those two
|
|
7
|
+
// reads with the image on the LOCAL clipboard, fetched through the SSH reverse
|
|
8
|
+
// tunnel. Every other call is passed on to the real xclip, if any.
|
|
9
|
+
// The token stays in a 0600 file read with `curl -H @file`, so it never
|
|
10
|
+
// appears in a process list on the server.
|
|
11
|
+
export const XCLIP_SHIM = `#!/bin/sh
|
|
12
|
+
# claude-remote: serve clipboard image reads from the LOCAL computer.
|
|
13
|
+
dir=$(cd -- "$(dirname -- "$0")" && pwd -P)
|
|
14
|
+
selection=primary target= output= previous=
|
|
15
|
+
for argument in "$@"; do
|
|
16
|
+
case $previous in
|
|
17
|
+
-selection|-sel) selection=$argument ;;
|
|
18
|
+
-t|-target) target=$argument ;;
|
|
19
|
+
esac
|
|
20
|
+
case $argument in -o|-out) output=1 ;; esac
|
|
21
|
+
previous=$argument
|
|
22
|
+
done
|
|
23
|
+
|
|
24
|
+
real() {
|
|
25
|
+
PATH=$(printf '%s' "$PATH" | tr ':' '\\n' | grep -vxF -- "$dir" | paste -s -d: -)
|
|
26
|
+
export PATH
|
|
27
|
+
command -v xclip >/dev/null 2>&1 || exit 1
|
|
28
|
+
exec xclip "$@"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Download the LOCAL clipboard image to $1; fails when there is none (HTTP 204).
|
|
32
|
+
fetch() {
|
|
33
|
+
part=$1.part
|
|
34
|
+
rm -f -- "$part"
|
|
35
|
+
if command -v curl >/dev/null 2>&1; then
|
|
36
|
+
curl -sf --max-time 20 -H @"$dir/header" -o "$part" "$(cat -- "$dir/url")" 2>/dev/null
|
|
37
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
38
|
+
python3 - "$dir" "$part" <<'PYTHON' 2>/dev/null
|
|
39
|
+
import sys, urllib.request
|
|
40
|
+
directory, target = sys.argv[1], sys.argv[2]
|
|
41
|
+
name, value = open(directory + '/header').read().strip().split(': ', 1)
|
|
42
|
+
request = urllib.request.Request(open(directory + '/url').read().strip(), headers={name: value})
|
|
43
|
+
with urllib.request.urlopen(request, timeout=20) as response, open(target, 'wb') as output:
|
|
44
|
+
output.write(response.read())
|
|
45
|
+
PYTHON
|
|
46
|
+
else
|
|
47
|
+
return 1
|
|
48
|
+
fi
|
|
49
|
+
if [ -s "$part" ]; then mv -f -- "$part" "$1"; else rm -f -- "$part"; return 1; fi
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if [ "$output" = 1 ] && [ "$selection" = clipboard ]; then
|
|
53
|
+
cache=$dir/clipboard.png
|
|
54
|
+
case $target in
|
|
55
|
+
TARGETS)
|
|
56
|
+
fetch "$cache" || exit 1
|
|
57
|
+
printf 'TARGETS\\nimage/png\\n'
|
|
58
|
+
exit 0 ;;
|
|
59
|
+
image/png)
|
|
60
|
+
# Reuse the image fetched by the TARGETS check a moment ago.
|
|
61
|
+
modified=$(stat -c %Y -- "$cache" 2>/dev/null || stat -f %m -- "$cache" 2>/dev/null || echo 0)
|
|
62
|
+
age=$(( $(date +%s) - modified ))
|
|
63
|
+
if [ ! -s "$cache" ] || [ "$age" -gt 30 ]; then fetch "$cache" || exit 1; fi
|
|
64
|
+
cat -- "$cache"
|
|
65
|
+
rm -f -- "$cache"
|
|
66
|
+
exit 0 ;;
|
|
67
|
+
esac
|
|
68
|
+
fi
|
|
69
|
+
real "$@"
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
// Claude Code on Linux pastes images with Ctrl+V only, but Windows terminals
|
|
73
|
+
// keep Ctrl+V for their own paste, so Windows users press Alt+V as they would
|
|
74
|
+
// locally. This binding is honoured even with --restricted.
|
|
75
|
+
export const ALT_V_BINDING = { context: 'Chat', bindings: { 'alt+v': 'chat:imagePaste' } };
|
|
76
|
+
export const KEYBINDING_MISSING = 'CLAUDE_REMOTE_ALT_V_MISSING';
|
|
77
|
+
|
|
78
|
+
// Creates ~/.claude/keybindings.json with the Alt+V binding when it does not
|
|
79
|
+
// exist. An existing file is never edited; KEYBINDING_MISSING is printed
|
|
80
|
+
// instead when it has no Alt+V entry, so the user can add it.
|
|
81
|
+
export function ensureAltVBinding() {
|
|
82
|
+
const file = '"$HOME/.claude/keybindings.json"';
|
|
83
|
+
// Failing to write is not fatal: Ctrl+V still works where the terminal allows it.
|
|
84
|
+
return 'if [ ! -e ' + file + ' ]; then { mkdir -p -- "$HOME/.claude" && printf \'%s\\n\' ' +
|
|
85
|
+
quote(JSON.stringify({ bindings: [ALT_V_BINDING] }, null, 2)) + ' > ' + file + '; } 2>/dev/null || true\n' +
|
|
86
|
+
'elif ! grep -qi \'"alt+v"\' ' + file + ' 2>/dev/null; then echo ' + KEYBINDING_MISSING + '\nfi\n';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Shell lines that install the stand-in into `directory` (an already quoted
|
|
90
|
+
// remote path). The Authorization header is read from the next stdin line.
|
|
91
|
+
export function installClipboardShim(directory, url) {
|
|
92
|
+
return 'mkdir -m 700 -- ' + directory + '\n' +
|
|
93
|
+
'IFS= read -r line; printf \'%s\\n\' "$line" > ' + directory + '/header\n' +
|
|
94
|
+
'printf \'%s\\n\' ' + url + ' > ' + directory + '/url\n' +
|
|
95
|
+
'cat > ' + directory + "/xclip <<'CLAUDE_REMOTE_XCLIP'\n" + XCLIP_SHIM + 'CLAUDE_REMOTE_XCLIP\n' +
|
|
96
|
+
'chmod 700 -- ' + directory + '/xclip\n';
|
|
97
|
+
}
|