@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.
@@ -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
+ }
package/src/ssh-auth.js CHANGED
@@ -89,7 +89,7 @@ export async function ensureSSH(client, config, { enablePubkey = false } = {}) {
89
89
  if (!await probe(['-i', key, '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none']))
90
90
  throw new Error('公钥已添加,但独立密钥登录验证失败。请检查服务器的公钥登录策略和 authorized_keys 设置;未修改本机 SSH 配置。');
91
91
 
92
- // SSH's ordinary config also makes the key available to Mutagen and scp.
92
+ // SSH's ordinary config also makes the key available to plain ssh and scp.
93
93
  // IdentityFile is additive; do not replace existing host/user/proxy rules.
94
94
  const configFile = path.join(client.home, '.ssh', 'config');
95
95
  const host = config.host.split('@').at(-1);
package/src/installer.js DELETED
@@ -1,96 +0,0 @@
1
- import * as fs from 'node:fs/promises';
2
- import { createWriteStream, createReadStream } from 'node:fs';
3
- import { createHash } from 'node:crypto';
4
- import { Readable, Transform } from 'node:stream';
5
- import { pipeline } from 'node:stream/promises';
6
- import os from 'node:os';
7
- import path from 'node:path';
8
- import { execute, exists, rename, which, withLock } from './system.js';
9
-
10
- export const VERSION = '0.18.1';
11
- // SHA256SUMS from the official v0.18.1 release, pinned in this package.
12
- export const RELEASES = {
13
- 'darwin-x64': ['darwin_amd64', '7d06f7d8fcfe90bc7e55cc834a2f2f20c2e0af9ea9bc35911fc4341ad56a9bbf'],
14
- 'darwin-arm64': ['darwin_arm64', '6f810416d9e5fc4fd5e18431146f8b3c5a2056ba5a24f76c1e66da86eb3257e2'],
15
- 'linux-x64': ['linux_amd64', '7735286c778cc438418209f24d03a64f3a0151c8065ef0fe079cfaf093af6f8f'],
16
- 'linux-arm64': ['linux_arm64', 'bcba735aebf8cbc11da9b3742118a665599ac697fa06bc5751cac8dcd540db8a'],
17
- 'win32-x64': ['windows_amd64', '3e237e77f69959ed520a0f877330a431507bb0a85d9da7919764ba0c87b702c7'],
18
- 'win32-arm64': ['windows_arm64', '9ac53447e46f019be9d37f49c00eeed8635966b885ed29ef06b3ff19afdee532']
19
- };
20
-
21
- const executableName = (platform = process.platform) => platform === 'win32' ? 'mutagen.exe' : 'mutagen';
22
-
23
- // Windows ships bsdtar in System32, which reads these archives. A GNU tar
24
- // earlier in PATH (e.g. from Git) would treat "C:" in a path as a remote host.
25
- export async function findTar(env = process.env, platform = process.platform) {
26
- if (platform === 'win32') {
27
- const system = path.win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', 'tar.exe');
28
- if (await exists(system)) return system;
29
- }
30
- return which('tar', env.PATH, { platform });
31
- }
32
-
33
- export function cachedBinary(env = process.env) {
34
- return path.join(env.CLAUDE_REMOTE_CACHE_DIR || path.join(os.homedir(), '.cache', 'claude-remote'),
35
- `mutagen-${VERSION}-${process.platform}-${process.arch}`, executableName());
36
- }
37
-
38
- export async function verifyChecksum(file, expected) {
39
- const hash = createHash('sha256');
40
- for await (const chunk of createReadStream(file)) hash.update(chunk);
41
- if (hash.digest('hex') !== expected) throw new Error('同步工具 SHA-256 校验失败,已拒绝安装。');
42
- }
43
-
44
- async function download(url, file) {
45
- const response = await fetch(url, { signal: AbortSignal.timeout(180000) });
46
- if (!response.ok || !response.body) throw new Error(`下载同步工具失败:HTTP ${response.status}`);
47
- let bytes = 0;
48
- const limit = new Transform({ transform(chunk, encoding, done) {
49
- bytes += chunk.length;
50
- done(bytes > 200 * 1024 * 1024 ? new Error('同步工具下载大小异常。') : null, chunk);
51
- } });
52
- await pipeline(Readable.fromWeb(response.body), limit, createWriteStream(file, { flags: 'wx', mode: 0o600 }));
53
- }
54
-
55
- export async function ensureMutagen({ env = process.env, forceDownload = false } = {}) {
56
- if (!RELEASES[`${process.platform}-${process.arch}`]) throw new Error('同步工具支持 macOS、Linux 和 Windows 的 x64、arm64。');
57
- if (env.CLAUDE_REMOTE_MUTAGEN) {
58
- const binary = path.resolve(env.CLAUDE_REMOTE_MUTAGEN);
59
- await execute(binary, ['version'], { capture: true, timeout: 10000, env });
60
- return binary;
61
- }
62
- const binary = cachedBinary(env);
63
- if (await exists(binary) && await exists(path.join(path.dirname(binary), 'mutagen-agents.tar.gz'))) return binary;
64
- if (!forceDownload) {
65
- const system = await which('mutagen', env.PATH);
66
- if (system) {
67
- const result = await execute(system, ['version'], { capture: true, timeout: 10000, env, check: false });
68
- if (result.code === 0 && result.stdout.trim() === VERSION) return system;
69
- }
70
- }
71
- const destination = path.dirname(binary);
72
- return withLock(destination + '.lock', async () => {
73
- if (await exists(binary) && await exists(path.join(destination, 'mutagen-agents.tar.gz'))) return binary;
74
- const tar = await findTar(env);
75
- if (!tar) throw new Error('找不到系统 tar,无法解压同步工具。请安装 tar 后重试(Windows 10 1803 及以上自带)。');
76
- const [platform, checksum] = RELEASES[`${process.platform}-${process.arch}`];
77
- const archiveName = `mutagen_${platform}_v${VERSION}.tar.gz`;
78
- const temporary = await fs.mkdtemp(path.join(path.dirname(destination), '.mutagen-install-'));
79
- try {
80
- const archive = path.join(temporary, 'download.tar.gz');
81
- console.error('正在下载同步工具…');
82
- await download(`https://github.com/mutagen-io/mutagen/releases/download/v${VERSION}/${archiveName}`, archive);
83
- await verifyChecksum(archive, checksum);
84
- // Extract only the two required files from the authenticated archive.
85
- const unpacked = path.join(temporary, 'unpacked');
86
- await fs.mkdir(unpacked);
87
- const name = executableName();
88
- await execute(tar, ['-xzf', archive, '-C', unpacked, name, 'mutagen-agents.tar.gz'], { timeout: 60000, env });
89
- await fs.chmod(path.join(unpacked, name), 0o755);
90
- await execute(path.join(unpacked, name), ['version'], { capture: true, timeout: 10000, env });
91
- if (await exists(destination)) await rename(destination, path.join(temporary, 'old-cache'));
92
- await rename(unpacked, destination);
93
- return binary;
94
- } finally { await fs.rm(temporary, { recursive: true, force: true }); }
95
- });
96
- }