@znmin/claude-remote-cli 0.2.7 → 0.3.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 CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 安装
8
8
 
9
- 要求:Node.js **20.11 或更高版本**、npm 和 OpenSSH 客户端。本地模式需要 `ssh`,自动配置免密登录需要 `ssh-keygen`,服务器同步模式另外需要 `scp` 和系统 `tar`。支持 macOS/Linux 的 x64、arm64;Windows 请在 WSL 内使用。本地无需 Python、Homebrew 或 Anthropic 的 Claude Code。
9
+ 要求:Node.js **20.11 或更高版本**、npm 和 OpenSSH 客户端。本地模式需要 `ssh`,自动配置免密登录需要 `ssh-keygen`,服务器同步模式另外需要 `scp` 和系统 `tar`。支持 macOS、Linux 和 Windows 10/11 的 x64、arm64(Windows 见下方说明,也仍可在 WSL 内按 Linux 方式使用)。本地无需 Python、Homebrew 或 Anthropic 的 Claude Code。
10
10
 
11
11
  npm 包名为 `@znmin/claude-remote-cli`,安装后提供 `claude` 和 `claude-remote` 命令。
12
12
 
@@ -25,16 +25,34 @@ 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.2.7.tgz
28
+ npm install -g ./znmin-claude-remote-cli-0.3.1.tgz
29
29
  ```
30
30
 
31
- 若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.2.7.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
31
+ 若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.3.1.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
32
32
 
33
33
  npm 中不带作用域的 `claude-remote-cli` 属于其他项目。本项目使用独立的 `@znmin/claude-remote-cli` 作用域包名;请安装完整包名,或使用上述 GitHub 地址及本项目分发的 `.tgz` 文件。
34
34
 
35
35
  依赖按模式准备:本地模式首次启动准备 Playwright Chromium,服务器模式首次启动准备 Mutagen 0.18.1。Mutagen 复用 PATH 中的匹配版本,或从官方 GitHub Release 下载并校验内置 SHA-256。浏览器和同步组件需要网络和额外磁盘空间;npm 安装本身不强制下载这些二进制文件。
36
36
 
37
- 如果用户已经安装其他提供 `claude` 命令的软件,应先卸载旧客户端,避免 npm 命令冲突或 PATH 指向旧程序。无需使用 `--force` 覆盖。
37
+ ### Windows(0.3.0)
38
+
39
+ 在 PowerShell 或 Windows Terminal 中直接安装和运行,与 macOS/Linux 使用同一套命令:
40
+
41
+ ```powershell
42
+ npm install -g @znmin/claude-remote-cli
43
+ claude-remote configure user@example.com
44
+ cd C:\path\to\project
45
+ claude
46
+ ```
47
+
48
+ - 需要 Windows 自带的 OpenSSH 客户端(Windows 11 和 Windows 10 1809 以上默认安装;若 `ssh` 不可用,在“设置 → 系统 → 可选功能”中添加“OpenSSH 客户端”)。服务器同步模式另外使用系统自带的 `C:\Windows\System32\tar.exe`。
49
+ - 本地终端工具优先使用 Git Bash([Git for Windows](https://git-scm.com/download/win),与 Claude Code 原生 Windows 版相同的查找顺序,也读取 `CLAUDE_CODE_GIT_BASH_PATH`);未安装时使用 PowerShell(优先 `pwsh`)。可用 `CLAUDE_REMOTE_SHELL` 指定 `bash.exe` 或 `powershell.exe`/`pwsh.exe` 的完整路径。`claude-remote doctor` 会显示当前选择。建议安装 Git Bash,模型编写 bash 命令更可靠。
50
+ - Windows 的 OpenSSH 不支持连接复用(ControlMaster)。本地模式的反向隧道使用独立 SSH 连接,其他服务器命令各自建立连接,依赖已配置的免密登录。首次 `setup-ssh` 可能需要输入两次服务器密码(读取服务器策略、添加公钥各一次);完成后不再需要。`~/.ssh/config` 中写入的 `IdentityFile` 使用正斜杠路径。
51
+ - 停止本地后台进程时使用 `taskkill /T /F` 结束整个进程树;父进程已退出后自行脱离的子进程不在清理范围内。
52
+ - 文件工具在 Windows 上按不区分大小写的方式判断路径是否位于项目内。CRLF 换行的文件读取和搜索时不显示 `\r`,`edit_file` 按 LF 文本匹配时会保留原有 CRLF 换行(适用于所有平台)。
53
+ - 卸载、配置和缓存目录与其他平台相同,分别位于 `%USERPROFILE%\.config\claude-remote`、`%USERPROFILE%\.cache\claude-remote` 和 `%USERPROFILE%\.ssh\claude-remote`。
54
+
55
+ 如果用户已经安装其他提供 `claude` 命令的软件,应先卸载旧客户端,避免 npm 命令冲突或 PATH 指向旧程序。无需使用 `--force` 覆盖。Windows 上如果 `where.exe claude` 指向本工具,运行 `claude` 却报错(如 `claude.exe not found on PATH`),通常是 PowerShell 配置文件中残留了同名函数或别名;`claude-remote doctor` 会列出它及其所在文件。
38
56
 
39
57
  ## 开始使用
40
58
 
@@ -220,6 +238,7 @@ CLAUDE_REMOTE_DEBUG=1 claude
220
238
  | `CLAUDE_REMOTE_PREPARE_SYNC=1` | npm 安装阶段提前准备同步工具,默认按需准备 |
221
239
  | `CLAUDE_REMOTE_SKIP_DOWNLOAD=1` | 跳过安装阶段的同步工具准备 |
222
240
  | `CLAUDE_REMOTE_DEBUG=1` | 显示详细启动过程和错误堆栈 |
241
+ | `CLAUDE_REMOTE_SHELL` | 仅 Windows:本地终端使用的 `bash.exe` 或 `pwsh.exe`/`powershell.exe` 完整路径 |
223
242
 
224
243
  `npm install --ignore-scripts` 也受支持,首次使用各模式时准备对应组件。无法访问 GitHub 时可自行准备 Mutagen 并使用 `CLAUDE_REMOTE_MUTAGEN`。浏览器缓存由 Playwright 管理,支持其 `PLAYWRIGHT_BROWSERS_PATH` 环境变量。
225
244
 
@@ -231,12 +250,13 @@ CLAUDE_REMOTE_DEBUG=1 claude
231
250
  npm ci --ignore-scripts
232
251
  npm run lint
233
252
  npm test
253
+ npm run test:windows # Windows 上运行的客户端测试(CI 在 windows-latest 运行)
234
254
 
235
255
  # 可选:真实本地同步与受控浏览器测试
236
256
  CLAUDE_REMOTE_INTEGRATION=1 CLAUDE_REMOTE_BROWSER_TEST=1 npm test
237
257
 
238
258
  npm pack
239
- npm install -g ./znmin-claude-remote-cli-0.2.7.tgz
259
+ npm install -g ./znmin-claude-remote-cli-0.3.1.tgz
240
260
 
241
261
  # 确定包名、版本和使用条款后,使用有发布权限的 npm 账号发布
242
262
  npm login
@@ -245,6 +265,6 @@ npm publish --access public
245
265
 
246
266
  GitHub 仓库名为 `claude-remote-cli`,npm 包名为 `@znmin/claude-remote-cli`,命令名为 `claude` 和 `claude-remote`。发布账号必须拥有 npm 用户作用域 `@znmin`,或拥有同名 npm 组织的发布权限;GitHub 身份不会自动授予 npm 权限。当前 `license` 为 `UNLICENSED`,尚未指定开源许可证。
247
267
 
248
- 测试覆盖真实 MCP HTTP 调用、本机文件与进程、受控浏览器交互、本地真实同步、模拟 SSH/Claude 的完整启动清理链路,以及官方安装流程的隔离模拟。真实服务器完整启动、Claude 模型端到端调用、Linux 和其他 CPU 架构尚未完成实测。
268
+ 测试覆盖真实 MCP HTTP 调用、本机文件与进程、受控浏览器交互、本地真实同步、模拟 SSH/Claude 的完整启动清理链路,以及官方安装流程的隔离模拟。真实服务器完整启动、Claude 模型端到端调用、Linux 和其他 CPU 架构尚未完成实测。Windows 客户端的命令构造、隧道端口解析和免密配置流程在 macOS 上以模拟方式测试,真实 Windows 终端测试在 CI 的 windows-latest 运行;尚未在真实 Windows 电脑上连接服务器实测。
249
269
 
250
270
  参考:[npm 命令入口配置](https://docs.npmjs.com/cli/configuring-npm/package-json#bin)、[Mutagen 同步机制](https://mutagen.io/documentation/synchronization/)、[Mutagen SSH 支持](https://mutagen.io/documentation/transports/ssh/)。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@znmin/claude-remote-cli",
3
- "version": "0.2.7",
4
- "description": "Run Claude Code on an SSH server with local filesystem, terminal and browser tools, or remote bidirectional sync",
3
+ "version": "0.3.1",
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",
7
7
  "engines": {
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "os": [
11
11
  "darwin",
12
- "linux"
12
+ "linux",
13
+ "win32"
13
14
  ],
14
15
  "cpu": [
15
16
  "x64",
@@ -30,6 +31,7 @@
30
31
  "postinstall": "node scripts/postinstall.js",
31
32
  "lint": "eslint .",
32
33
  "test": "node --test test/*.test.js",
34
+ "test:windows": "node --test test/windows.test.js test/local-tools.test.js",
33
35
  "prepublishOnly": "npm run lint && npm test"
34
36
  },
35
37
  "keywords": [
package/src/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { Client, DEFAULTS } from './client.js';
4
- import { cachedBinary, ensureMutagen, VERSION } from './installer.js';
4
+ import { cachedBinary, ensureMutagen, findTar, VERSION } from './installer.js';
5
5
  import { exists, installSignalHandlers, readJSON, which } from './system.js';
6
6
  import { readFileSync } from 'node:fs';
7
7
 
@@ -121,7 +121,15 @@ export async function main(asClaude, args = process.argv.slice(2), client = new
121
121
  const [major, minor] = process.versions.node.split('.').map(Number);
122
122
  const nodeOk = major > 20 || major === 20 && minor >= 11;
123
123
  console.log(`Node.js:${process.version}${nodeOk ? '' : '(需要 20.11 或更高版本)'}\n平台:${process.platform}/${process.arch}`);
124
- for (const tool of ['ssh', 'ssh-keygen', 'scp', 'tar']) console.log(`${tool}:${await which(tool, client.env.PATH) || '未安装'}`);
124
+ for (const tool of ['ssh', 'ssh-keygen', 'scp']) console.log(`${tool}:${await which(tool, client.env.PATH) || '未安装'}`);
125
+ console.log(`tar:${await findTar(client.env) || '未安装'}`);
126
+ if (process.platform === 'win32') {
127
+ const { describeShell, findShell } = await import('./local/shell.js');
128
+ const shell = await findShell(client.env);
129
+ console.log(`本地终端:${describeShell(shell)}(${shell.file})${shell.kind === 'powershell' ? ';安装 Git for Windows 后改用 Git Bash' : ''}`);
130
+ const { conflictReport } = await import('./powershell-conflicts.js');
131
+ for (const line of await conflictReport(client.env)) console.log(line);
132
+ }
125
133
  try {
126
134
  const { browserReady } = await import('./local/browser.js');
127
135
  const { chromium } = await import('playwright');
package/src/client.js CHANGED
@@ -54,6 +54,7 @@ export class Client {
54
54
  this.env = options.env || process.env;
55
55
  this.home = options.home || os.homedir();
56
56
  this.cwd = options.cwd || process.cwd();
57
+ this.platform = options.platform || process.platform;
57
58
  this.directory = options.configDir || this.env.CLAUDE_REMOTE_CONFIG_DIR || path.join(this.home, '.config', 'claude-remote');
58
59
  this.configFile = path.join(this.directory, 'config.json');
59
60
  this.registryFile = path.join(this.directory, 'projects.json');
package/src/installer.js CHANGED
@@ -5,7 +5,7 @@ import { Readable, Transform } from 'node:stream';
5
5
  import { pipeline } from 'node:stream/promises';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
- import { execute, exists, which, withLock } from './system.js';
8
+ import { execute, exists, rename, which, withLock } from './system.js';
9
9
 
10
10
  export const VERSION = '0.18.1';
11
11
  // SHA256SUMS from the official v0.18.1 release, pinned in this package.
@@ -13,12 +13,26 @@ export const RELEASES = {
13
13
  'darwin-x64': ['darwin_amd64', '7d06f7d8fcfe90bc7e55cc834a2f2f20c2e0af9ea9bc35911fc4341ad56a9bbf'],
14
14
  'darwin-arm64': ['darwin_arm64', '6f810416d9e5fc4fd5e18431146f8b3c5a2056ba5a24f76c1e66da86eb3257e2'],
15
15
  'linux-x64': ['linux_amd64', '7735286c778cc438418209f24d03a64f3a0151c8065ef0fe079cfaf093af6f8f'],
16
- 'linux-arm64': ['linux_arm64', 'bcba735aebf8cbc11da9b3742118a665599ac697fa06bc5751cac8dcd540db8a']
16
+ 'linux-arm64': ['linux_arm64', 'bcba735aebf8cbc11da9b3742118a665599ac697fa06bc5751cac8dcd540db8a'],
17
+ 'win32-x64': ['windows_amd64', '3e237e77f69959ed520a0f877330a431507bb0a85d9da7919764ba0c87b702c7'],
18
+ 'win32-arm64': ['windows_arm64', '9ac53447e46f019be9d37f49c00eeed8635966b885ed29ef06b3ff19afdee532']
17
19
  };
18
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
+
19
33
  export function cachedBinary(env = process.env) {
20
34
  return path.join(env.CLAUDE_REMOTE_CACHE_DIR || path.join(os.homedir(), '.cache', 'claude-remote'),
21
- `mutagen-${VERSION}-${process.platform}-${process.arch}`, 'mutagen');
35
+ `mutagen-${VERSION}-${process.platform}-${process.arch}`, executableName());
22
36
  }
23
37
 
24
38
  export async function verifyChecksum(file, expected) {
@@ -39,7 +53,7 @@ async function download(url, file) {
39
53
  }
40
54
 
41
55
  export async function ensureMutagen({ env = process.env, forceDownload = false } = {}) {
42
- if (!RELEASES[`${process.platform}-${process.arch}`]) throw new Error('此版本支持 macOS/Linux 的 x64、arm64;Windows 请在 WSL 中使用。');
56
+ if (!RELEASES[`${process.platform}-${process.arch}`]) throw new Error('同步工具支持 macOS、Linux 和 Windows 的 x64、arm64。');
43
57
  if (env.CLAUDE_REMOTE_MUTAGEN) {
44
58
  const binary = path.resolve(env.CLAUDE_REMOTE_MUTAGEN);
45
59
  await execute(binary, ['version'], { capture: true, timeout: 10000, env });
@@ -57,8 +71,8 @@ export async function ensureMutagen({ env = process.env, forceDownload = false }
57
71
  const destination = path.dirname(binary);
58
72
  return withLock(destination + '.lock', async () => {
59
73
  if (await exists(binary) && await exists(path.join(destination, 'mutagen-agents.tar.gz'))) return binary;
60
- const tar = await which('tar', env.PATH);
61
- if (!tar) throw new Error('找不到系统 tar,无法解压同步工具。请安装 tar 后重试。');
74
+ const tar = await findTar(env);
75
+ if (!tar) throw new Error('找不到系统 tar,无法解压同步工具。请安装 tar 后重试(Windows 10 1803 及以上自带)。');
62
76
  const [platform, checksum] = RELEASES[`${process.platform}-${process.arch}`];
63
77
  const archiveName = `mutagen_${platform}_v${VERSION}.tar.gz`;
64
78
  const temporary = await fs.mkdtemp(path.join(path.dirname(destination), '.mutagen-install-'));
@@ -70,11 +84,12 @@ export async function ensureMutagen({ env = process.env, forceDownload = false }
70
84
  // Extract only the two required files from the authenticated archive.
71
85
  const unpacked = path.join(temporary, 'unpacked');
72
86
  await fs.mkdir(unpacked);
73
- await execute(tar, ['-xzf', archive, '-C', unpacked, 'mutagen', 'mutagen-agents.tar.gz'], { timeout: 60000, env });
74
- await fs.chmod(path.join(unpacked, 'mutagen'), 0o755);
75
- await execute(path.join(unpacked, 'mutagen'), ['version'], { capture: true, timeout: 10000, env });
76
- if (await exists(destination)) await fs.rename(destination, path.join(temporary, 'old-cache'));
77
- await fs.rename(unpacked, destination);
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);
78
93
  return binary;
79
94
  } finally { await fs.rm(temporary, { recursive: true, force: true }); }
80
95
  });
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { quote } from '../system.js';
3
+ import path from 'node:path';
4
+ import { findShell, prepareCommand } from './shell.js';
4
5
 
5
6
  // Characters retained per stream; older output is dropped but offsets stay absolute.
6
7
  const LIMIT = 256 * 1024;
@@ -8,7 +9,12 @@ const PREVIEW = 8000;
8
9
  const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
9
10
 
10
11
  export class Processes {
11
- constructor(workspace, env = process.env) { this.workspace = workspace; this.env = env; this.jobs = new Map(); }
12
+ constructor(workspace, env = process.env, { platform = process.platform, shell } = {}) {
13
+ this.workspace = workspace; this.env = env; this.platform = platform; this.jobs = new Map();
14
+ if (shell) this.shellChoice = Promise.resolve(shell);
15
+ }
16
+
17
+ shell() { return this.shellChoice ||= findShell(this.env, this.platform); }
12
18
 
13
19
  job(id) {
14
20
  const job = this.jobs.get(id);
@@ -49,10 +55,16 @@ export class Processes {
49
55
  if ([...this.jobs.values()].filter(job => !job.ended).length >= 16) throw new Error('后台进程过多,请先停止部分进程。');
50
56
  for (const [id, job] of this.jobs) if (this.jobs.size >= 64 && job.ended) this.jobs.delete(id);
51
57
  const directory = await this.workspace.resolve(cwd);
52
- const shell = this.env.SHELL || '/bin/bash';
53
- const child = spawn(shell, ['-lc', `cd -- ${quote(directory)} && ${command}`], {
54
- cwd: directory, env: this.env, detached: true, stdio: ['ignore', 'pipe', 'pipe']
55
- });
58
+ const prepared = await prepareCommand(await this.shell(), command, directory, this.env);
59
+ const windows = this.platform === 'win32';
60
+ // POSIX: a new process group, stopped as a whole. Windows: no new console
61
+ // window; the process tree is stopped with taskkill instead.
62
+ let child;
63
+ try {
64
+ child = spawn(prepared.file, prepared.args, {
65
+ cwd: directory, env: prepared.env, detached: !windows, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe']
66
+ });
67
+ } catch (error) { await prepared.cleanup(); throw error; }
56
68
  const id = randomUUID();
57
69
  const job = { child, cwd: directory, ended: false,
58
70
  stdout: { text: '', dropped: 0 }, stderr: { text: '', dropped: 0 } };
@@ -70,7 +82,7 @@ export class Processes {
70
82
  });
71
83
  child.once('error', error => { job.stderr.text += error.message; job.code = 127; job.ended = true; resolve(); });
72
84
  child.once('close', (code, sig) => { job.code = code; job.signal = sig; job.ended = true; resolve(); });
73
- });
85
+ }).finally(prepared.cleanup);
74
86
  if (background) {
75
87
  await Promise.race([job.done, delay(80)]);
76
88
  return this.status(id);
@@ -91,9 +103,27 @@ export class Processes {
91
103
  catch (error) { if (error.code !== 'ESRCH') throw error; }
92
104
  }
93
105
 
106
+ // Console programs ignore taskkill's polite close request, so the tree is
107
+ // stopped forcefully. It only reaches processes whose parent is still alive.
108
+ killTree(job) {
109
+ if (!job.child.pid) return Promise.resolve();
110
+ const taskkill = path.win32.join(this.env.SystemRoot || this.env.SYSTEMROOT || 'C:\\Windows', 'System32', 'taskkill.exe');
111
+ return new Promise(resolve => {
112
+ const killer = spawn(taskkill, ['/PID', String(job.child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
113
+ killer.once('error', resolve); killer.once('close', resolve);
114
+ });
115
+ }
116
+
94
117
  async stop(id) {
95
118
  const job = this.job(id);
96
119
  if (job.ended) return this.status(id);
120
+ if (this.platform === 'win32') {
121
+ await this.killTree(job);
122
+ await Promise.race([job.done, delay(1500)]);
123
+ if (!job.ended) job.child.kill();
124
+ await Promise.race([job.done, delay(1000)]);
125
+ return this.status(id);
126
+ }
97
127
  // Kill the owned group even if its parent shell has already exited.
98
128
  this.killGroup(job, 'SIGTERM');
99
129
  await Promise.race([job.done, delay(1500)]);
@@ -8,6 +8,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
8
8
  import { z } from 'zod';
9
9
  import { Workspace } from './workspace.js';
10
10
  import { Processes } from './processes.js';
11
+ import { describeShell } from './shell.js';
11
12
  import { Browser } from './browser.js';
12
13
 
13
14
  const PACKAGE = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
@@ -60,8 +61,15 @@ export async function startLocalServer({ root, env = process.env, headless = fal
60
61
  });
61
62
  };
62
63
  register('workspace_info', 'Describe the LOCAL computer and project. All tools execute on this computer, not the SSH server.', {},
63
- () => ({ root: workspace.root, platform: os.platform(), shell: env.SHELL || '/bin/bash',
64
- instructions: 'Use local absolute or project-relative paths. Use background=true for development servers, then process_status for logs. Browser selectors come from browser_snapshot. Terminal commands run as the local user.' }), true);
64
+ async () => {
65
+ const shell = await processes.shell();
66
+ const windows = os.platform() === 'win32';
67
+ const shellNote = !windows ? '' : shell.kind === 'bash'
68
+ ? ' This is Windows: run_command uses Git Bash, so write bash; Windows programs (git, npm, node, python) are on PATH, and C:/path style paths work.'
69
+ : ' This is Windows without Git Bash: run_command uses PowerShell, so write PowerShell commands (not bash or cmd syntax).';
70
+ return { root: workspace.root, platform: os.platform(), shell: shell.file, ...(windows ? { shell_kind: describeShell(shell) } : {}),
71
+ instructions: 'Use local absolute or project-relative paths. Use background=true for development servers, then process_status for logs. Browser selectors come from browser_snapshot. Terminal commands run as the local user.' + shellNote };
72
+ }, true);
65
73
  register('read_file', 'Read a LOCAL project text file, with line numbers and a SHA-256 for safe replacement. Large reads stop early and return next_offset to continue from.', {
66
74
  path: file, offset: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(2000).default(200)
67
75
  }, args => workspace.read(args.path, args.offset, args.limit), true);
@@ -0,0 +1,72 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { exists, quote, which } from '../system.js';
6
+
7
+ const win = path.win32;
8
+ export const psQuote = value => "'" + String(value).replaceAll("'", "''") + "'";
9
+
10
+ // Git Bash locations, in the order Claude Code itself looks for them.
11
+ export async function gitBashCandidates(env = process.env) {
12
+ const candidates = [];
13
+ if (env.CLAUDE_CODE_GIT_BASH_PATH) candidates.push(env.CLAUDE_CODE_GIT_BASH_PATH);
14
+ const git = await which('git', env.PATH || env.Path || '', { platform: 'win32' });
15
+ if (git) {
16
+ // <Git>\cmd\git.exe, <Git>\bin\git.exe or <Git>\mingw64\bin\git.exe
17
+ let root = win.dirname(win.dirname(git));
18
+ if (/^mingw(32|64)$/i.test(win.basename(root))) root = win.dirname(root);
19
+ candidates.push(win.join(root, 'bin', 'bash.exe'));
20
+ }
21
+ for (const base of [env.ProgramFiles, env.ProgramW6432, env['ProgramFiles(x86)']]) if (base) candidates.push(win.join(base, 'Git', 'bin', 'bash.exe'));
22
+ if (env.LOCALAPPDATA) candidates.push(win.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'));
23
+ return [...new Set(candidates)];
24
+ }
25
+
26
+ // The shell behind run_command. POSIX keeps the user's login shell. Windows
27
+ // prefers Git Bash (the model writes bash most reliably), then PowerShell.
28
+ // C:\Windows\System32\bash.exe is WSL, which would run in a different system,
29
+ // so bash is never taken from PATH there.
30
+ export async function findShell(env = process.env, platform = process.platform) {
31
+ if (platform !== 'win32') return { kind: 'posix', file: env.SHELL || '/bin/bash' };
32
+ const chosen = env.CLAUDE_REMOTE_SHELL;
33
+ if (chosen) return { kind: /(^|[\\/])(pwsh|powershell)(\.exe)?$/i.test(chosen) ? 'powershell' : 'bash', file: chosen };
34
+ for (const file of await gitBashCandidates(env)) if (await exists(file)) return { kind: 'bash', file };
35
+ const pwsh = await which('pwsh', env.PATH || env.Path || '', { platform });
36
+ if (pwsh) return { kind: 'powershell', file: pwsh };
37
+ return { kind: 'powershell',
38
+ file: win.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') };
39
+ }
40
+
41
+ export function describeShell(shell) {
42
+ if (shell.kind === 'bash') return 'Git Bash';
43
+ if (shell.kind === 'powershell') return 'PowerShell';
44
+ return path.basename(shell.file);
45
+ }
46
+
47
+ // Windows limits a command line to 32767 characters, so commands there are
48
+ // written to a temporary script instead of being passed as an argument.
49
+ export async function prepareCommand(shell, command, directory, env = process.env) {
50
+ if (shell.kind === 'posix')
51
+ return { file: shell.file, args: ['-lc', `cd -- ${quote(directory)} && ${command}`], env, cleanup: async () => {} };
52
+ const script = path.join(os.tmpdir(), `claude-remote-${randomUUID()}.${shell.kind === 'bash' ? 'sh' : 'ps1'}`);
53
+ const cleanup = () => fs.rm(script, { force: true }).catch(() => {});
54
+ if (shell.kind === 'bash') {
55
+ // MSYS accepts C:/x paths. CHERE_INVOKING stops the login profile from changing to $HOME.
56
+ await fs.writeFile(script, `cd -- ${quote(directory.replaceAll('\\', '/'))} || exit 1\n${command}\n`, { mode: 0o600 });
57
+ return { file: shell.file, args: ['-l', script.replaceAll('\\', '/')], env: { ...env, CHERE_INVOKING: '1' }, cleanup };
58
+ }
59
+ // A BOM makes Windows PowerShell 5.1 read the script as UTF-8. Mirror bash:
60
+ // the exit code follows the last statement, including native programs.
61
+ await fs.writeFile(script, '\uFEFF' + [
62
+ "$ProgressPreference = 'SilentlyContinue'",
63
+ 'try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}',
64
+ '$OutputEncoding = [System.Text.Encoding]::UTF8',
65
+ 'Set-Location -LiteralPath ' + psQuote(directory),
66
+ command,
67
+ 'if (-not $?) { if ($LASTEXITCODE) { exit $LASTEXITCODE }; exit 1 }',
68
+ 'exit 0', ''
69
+ ].join('\r\n'), { mode: 0o600 });
70
+ return { file: shell.file, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script],
71
+ env, cleanup };
72
+ }
@@ -6,7 +6,55 @@ import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { execute, exists, trackChild } from '../system.js';
7
7
  import { sshBase } from '../ssh.js';
8
8
 
9
- export async function openTunnel(config, localPort, { env = process.env, run = execute } = {}) {
9
+ const ALLOCATED = /^Allocated port (\d+) for remote forward\b/;
10
+
11
+ // Windows OpenSSH has no connection multiplexing (ControlMaster), so the
12
+ // reverse forward runs in its own connection and the server-chosen port is
13
+ // read from ssh's "Allocated port" message. Other commands then open their own
14
+ // connections, which works because key login is set up before this point.
15
+ export async function openDirectTunnel(config, localPort, { env = process.env, spawnProcess = spawn, timeoutMs = 120000 } = {}) {
16
+ const child = trackChild(spawnProcess('ssh', [...sshBase(config), '-o', 'ExitOnForwardFailure=yes', '-o', 'LogLevel=INFO',
17
+ '-N', '-T', '-R', `127.0.0.1:0:127.0.0.1:${localPort}`, config.host],
18
+ { env, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true }));
19
+ let closed = false, errorText = '', spawnError, port, pending = '', announce;
20
+ const allocated = new Promise(resolve => { announce = resolve; });
21
+ const done = new Promise(resolve => {
22
+ child.once('error', error => { spawnError = error; closed = true; resolve(); announce(); });
23
+ child.once('close', () => { closed = true; resolve(); announce(); });
24
+ });
25
+ child.stderr.setEncoding('utf8');
26
+ child.stderr.on('data', chunk => {
27
+ const lines = (pending + chunk).split(/\r?\n/);
28
+ pending = lines.pop();
29
+ for (const line of lines) {
30
+ const match = line.match(ALLOCATED);
31
+ if (match && port === undefined) { port = Number(match[1]); announce(); continue; }
32
+ errorText = (errorText + line + '\n').slice(-8000);
33
+ process.stderr.write(line + '\n');
34
+ }
35
+ });
36
+ const stop = async () => {
37
+ if (closed) return;
38
+ child.kill('SIGTERM');
39
+ await Promise.race([done, delay(1000)]);
40
+ if (!closed) { child.kill('SIGKILL'); await done; }
41
+ };
42
+ try {
43
+ let timer;
44
+ const expired = new Promise(resolve => { timer = setTimeout(resolve, timeoutMs); });
45
+ await Promise.race([allocated, expired]);
46
+ clearTimeout(timer);
47
+ if (port === undefined) {
48
+ if (closed) throw new Error('SSH 工具连接失败:' + (spawnError?.message || errorText.trim() || '连接已关闭'));
49
+ throw new Error('无法获取 SSH 反向隧道端口。请检查服务器是否允许 TCP 转发。');
50
+ }
51
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('无法获取 SSH 反向隧道端口。请检查服务器是否允许 TCP 转发。');
52
+ return { controlPath: undefined, port, close: stop };
53
+ } catch (error) { await stop(); throw error; }
54
+ }
55
+
56
+ export async function openTunnel(config, localPort, { env = process.env, run = execute, platform = process.platform } = {}) {
57
+ if (platform === 'win32') return openDirectTunnel(config, localPort, { env });
10
58
  // Keep the control socket below the short Unix-domain path limit on macOS.
11
59
  const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
12
60
  const directory = await fs.mkdtemp(path.join(base, 'ccr-'));
@@ -2,9 +2,14 @@ import * as fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
+ import { rename } from '../system.js';
5
6
 
6
7
  const hash = text => createHash('sha256').update(text).digest('hex');
7
- const inside = (root, target) => target === root || target.startsWith(root + path.sep);
8
+ // Windows paths are case-insensitive: C:\Project and c:\project are the same folder.
9
+ const fold = process.platform === 'win32' ? value => value.toLowerCase() : value => value;
10
+ const inside = (root, target) => fold(target) === fold(root) || fold(target).startsWith(fold(root) + path.sep);
11
+ // Lines are shown without a trailing \r, so CRLF files read like LF files.
12
+ const stripCR = line => line.endsWith('\r') ? line.slice(0, -1) : line;
8
13
 
9
14
  // Keep read_file results below the MCP response limit (JSON escaping adds overhead).
10
15
  const READ_BUDGET = 28000;
@@ -77,7 +82,7 @@ export class Workspace {
77
82
  const output = [];
78
83
  let used = 0, sizeLimited = false;
79
84
  for (let i = offset - 1; i < Math.min(total, offset - 1 + limit); i++) {
80
- const line = `${i + 1}: ${lines[i]}`;
85
+ const line = `${i + 1}: ${stripCR(lines[i])}`;
81
86
  if (used + line.length > READ_BUDGET) {
82
87
  sizeLimited = true;
83
88
  if (!output.length) output.push(line.slice(0, READ_BUDGET) + ` …[本行过长已截断,共 ${lines[i].length} 字符,请用终端查看]`);
@@ -89,6 +94,7 @@ export class Workspace {
89
94
  const next = offset + output.length;
90
95
  return { path: path.relative(this.root, target), sha256: hash(content), total_lines: total,
91
96
  content: output.join('\n'),
97
+ ...(content.includes('\r\n') ? { line_endings: 'crlf' } : {}),
92
98
  ...(next <= total ? { next_offset: next } : {}),
93
99
  ...(sizeLimited ? { truncated_by_size: true } : {}) };
94
100
  }
@@ -98,7 +104,7 @@ export class Workspace {
98
104
  const temp = path.join(path.dirname(target), '.claude-write-' + randomUUID());
99
105
  try {
100
106
  await fs.writeFile(temp, content, { flag: 'wx', mode });
101
- await fs.rename(temp, target);
107
+ await rename(temp, target);
102
108
  } finally { await fs.rm(temp, { force: true }); }
103
109
  return { path: path.relative(this.root, target), sha256: hash(content), bytes: Buffer.byteLength(content) };
104
110
  }
@@ -121,6 +127,11 @@ export class Workspace {
121
127
  const stat = await fs.stat(target);
122
128
  if (stat.size > 2 * 1024 * 1024) throw new Error('编辑文件大小上限为 2 MiB。');
123
129
  const content = await fs.readFile(target, 'utf8');
130
+ // read_file hides \r, so text copied from it uses \n; match CRLF files too.
131
+ if (!content.includes(oldText) && content.includes('\r\n') && !oldText.includes('\r')) {
132
+ oldText = oldText.replaceAll('\n', '\r\n');
133
+ newText = newText.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n');
134
+ }
124
135
  const matches = content.split(oldText).length - 1;
125
136
  if (!matches) throw new Error('找不到 old_text,请重新读取文件。');
126
137
  if (matches > 1 && !replaceAll) throw new Error('old_text 出现多次,请提供更精确的上下文或使用 replace_all。');
@@ -192,7 +203,7 @@ export class Workspace {
192
203
  if (text.includes('\0')) return null;
193
204
  const found = [];
194
205
  for (const [i, line] of text.split('\n').entries())
195
- if (test(line)) found.push({ path: path.relative(this.root, target), line: i + 1, text: line.slice(0, 1000) });
206
+ if (test(stripCR(line))) found.push({ path: path.relative(this.root, target), line: i + 1, text: stripCR(line).slice(0, 1000) });
196
207
  return found;
197
208
  } catch (error) {
198
209
  if (error.code !== 'ENOENT') errors++; // Deleted tracked files are not errors.
package/src/local-mode.js CHANGED
@@ -87,7 +87,7 @@ export async function projectInstructions(root) {
87
87
  return sections.join('\n\n');
88
88
  }
89
89
 
90
- export function localPrompt(root, { instructions = '', appended = [] } = {}) {
90
+ export function localPrompt(root, { instructions = '', appended = [], platform = 'posix' } = {}) {
91
91
  const parts = [`All project operations run on the developer's LOCAL computer through the local MCP server. ` +
92
92
  `The LOCAL project root is ${JSON.stringify(root)}. The server working directory is only for session metadata, not project files. ` +
93
93
  'Call workspace_info before working. Use only local tools for file access, shell commands and browser operations. ' +
@@ -96,7 +96,8 @@ export function localPrompt(root, { instructions = '', appended = [] } = {}) {
96
96
  'Never send local development URLs to server-side WebFetch. If WebSearch or WebFetch is unavailable or fails for ordinary public research, report it and ask before using the local browser as a fallback. ' +
97
97
  'Project instructions from the LOCAL CLAUDE.md files are included below when present; also read CLAUDE.md files in LOCAL subdirectories with read_file when working there. ' +
98
98
  'Use background commands for development servers, inspect their logs, and stop owned processes when finished. ' +
99
- 'If the local connection fails, report it and stop local project operations; never fall back to server filesystem, shell or browser tools.'];
99
+ 'If the local connection fails, report it and stop local project operations; never fall back to server filesystem, shell or browser tools.' +
100
+ (platform === 'win32' ? ' The LOCAL computer runs Windows while the server runs Linux: use Windows paths for local tools and check workspace_info for the shell used by run_command.' : '')];
100
101
  if (instructions) parts.push('# Project instructions (LOCAL)\n\n' + instructions);
101
102
  if (appended.length) parts.push('# Additional instructions from the user\n\n' + appended.join('\n\n'));
102
103
  return parts.join('\n\n');
@@ -125,7 +126,8 @@ export async function launchLocal(client, config, extra, dependencies = {}) {
125
126
  if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
126
127
  const root = await fs.realpath(client.cwd), home = await fs.realpath(client.home);
127
128
  if (root === home || root === path.parse(root).root) throw new Error('请进入具体项目目录,不允许以用户目录或磁盘根目录作为项目。');
128
- const prompt = localPrompt(root, { instructions: await projectInstructions(root), appended: extractAppendedPrompts(extra).prompts });
129
+ const prompt = localPrompt(root, { instructions: await projectInstructions(root), appended: extractAppendedPrompts(extra).prompts,
130
+ platform: client.platform });
129
131
  // Prepare the browser while SSH connects; it is only needed before Claude starts.
130
132
  const browserReady = (dependencies.installBrowser || installBrowser)(client.log, { debug: client.debug });
131
133
  browserReady.catch(() => {}); // Reported when awaited below.
@@ -0,0 +1,67 @@
1
+ import path from 'node:path';
2
+ import { execute, which } from './system.js';
3
+
4
+ const MARKER = 'CLAUDE_REMOTE_JSON:';
5
+
6
+ // Runs with the user's profile loaded, as an interactive window would. Only
7
+ // aliases and functions are reported: they win over programs on PATH, so a
8
+ // leftover `function claude` hides this tool while where.exe shows nothing wrong.
9
+ const SCRIPT = [
10
+ "$ErrorActionPreference = 'SilentlyContinue'",
11
+ 'try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}',
12
+ '$items = @(Get-Command claude -All -CommandType Alias,Function,Filter | ForEach-Object {',
13
+ " $alias = $_.CommandType -eq 'Alias'",
14
+ ' [pscustomobject]@{ type = [string]$_.CommandType; target = $(if ($alias) { [string]$_.Definition } else { $null });',
15
+ ' file = $(if ($alias) { $null } else { [string]$_.ScriptBlock.File }); module = [string]$_.ModuleName }',
16
+ '})',
17
+ `'${MARKER}' + (ConvertTo-Json -Compress -Depth 3 -InputObject ([pscustomobject]@{ profile = [string]$PROFILE; items = $items }))`
18
+ ].join('\n');
19
+
20
+ const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64');
21
+
22
+ export async function powershellHosts(env = process.env) {
23
+ const hosts = [{ name: 'Windows PowerShell',
24
+ file: path.win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') }];
25
+ const pwsh = await which('pwsh', env.PATH || env.Path || '', { platform: 'win32' });
26
+ if (pwsh) hosts.push({ name: 'PowerShell 7', file: pwsh });
27
+ return hosts;
28
+ }
29
+
30
+ export function parseConflicts(stdout) {
31
+ const line = stdout.split(/\r?\n/).reverse().find(text => text.startsWith(MARKER));
32
+ if (!line) throw new Error('没有得到检查结果');
33
+ const result = JSON.parse(line.slice(MARKER.length));
34
+ const items = result.items == null ? [] : [].concat(result.items);
35
+ return { profile: result.profile || '', items };
36
+ }
37
+
38
+ export async function findClaudeConflicts(host, { env = process.env, run = execute, timeout = 20000 } = {}) {
39
+ const { stdout } = await run(host.file, ['-NoLogo', '-NonInteractive', '-EncodedCommand', encoded],
40
+ { capture: true, check: false, timeout, env });
41
+ return parseConflicts(stdout);
42
+ }
43
+
44
+ export function describeConflict(item) {
45
+ if (item.type === 'Alias') return `别名 → ${item.target}`;
46
+ const kind = item.type === 'Filter' ? '筛选器' : '函数';
47
+ if (item.file) return `${kind}(定义于 ${item.file})`;
48
+ if (item.module) return `${kind}(来自模块 ${item.module})`;
49
+ return kind;
50
+ }
51
+
52
+ // Lines for `claude-remote doctor`.
53
+ export async function conflictReport(env = process.env, options = {}) {
54
+ const lines = [];
55
+ for (const host of await powershellHosts(env)) {
56
+ try {
57
+ const { profile, items } = await findClaudeConflicts(host, { env, ...options });
58
+ if (!items.length) { lines.push(`${host.name} 中的 claude:无冲突`); continue; }
59
+ lines.push(`${host.name} 中的 claude:被占用,在该终端输入 claude 不会运行本工具`);
60
+ for (const item of items) lines.push(' - ' + describeConflict(item));
61
+ lines.push(` 处理:删除上述定义(配置文件 ${profile},可用 notepad $PROFILE 打开)后重新打开终端;临时可运行 claude.cmd`);
62
+ } catch (error) {
63
+ lines.push(`${host.name} 中的 claude:无法检查(${error.message})`);
64
+ }
65
+ }
66
+ return lines;
67
+ }
package/src/ssh-auth.js CHANGED
@@ -33,19 +33,28 @@ export async function ensureSSH(client, config, { enablePubkey = false } = {}) {
33
33
  if (!await which('ssh-keygen', client.env.PATH)) throw new Error('找不到 ssh-keygen,请安装 OpenSSH 客户端。');
34
34
 
35
35
  const directory = path.join(client.home, '.ssh', 'claude-remote');
36
+ // Windows OpenSSH cannot share one login through a control socket, so each
37
+ // step there opens its own connection and may ask for the password again.
38
+ const multiplex = (client.platform ?? process.platform) !== 'win32';
36
39
  await withLock(path.join(directory, 'setup'), async () => {
37
40
  if (await probe()) return;
38
- const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
39
- const temporary = await fs.mkdtemp(path.join(base, 'ccra-'));
40
- const socket = path.join(temporary, 'ssh');
41
+ let temporary, socket;
42
+ const login = args => multiplex ? ['-S', socket, ...args] : args;
43
+ // Without multiplexing, prompts belong to these commands: keep them visible and allow time to answer.
44
+ const prompted = multiplex ? {} : { prompt: true, timeout: 180000 };
41
45
  try {
42
- client.log('请按提示完成 SSH 验证。');
43
- // Authenticate once; subsequent installation reuses this private socket.
44
- // Do not capture authentication output or handle passwords in JavaScript.
45
- await run(['-M', '-S', socket, '-o', 'ControlPersist=no', '-o', 'ControlMaster=yes',
46
- '-f', '-N', config.host], { timeout: 180000, capture: false });
47
- const inspect = await run(['-S', socket, config.host, 'sh -c ' + quote(sshPolicyScript({ enable: enablePubkey }))],
48
- { capture: true });
46
+ if (multiplex) {
47
+ const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
48
+ temporary = await fs.mkdtemp(path.join(base, 'ccra-'));
49
+ socket = path.join(temporary, 'ssh');
50
+ client.log('请按提示完成 SSH 验证。');
51
+ // Authenticate once; subsequent installation reuses this private socket.
52
+ // Do not capture authentication output or handle passwords in JavaScript.
53
+ await run(['-M', '-S', socket, '-o', 'ControlPersist=no', '-o', 'ControlMaster=yes',
54
+ '-f', '-N', config.host], { timeout: 180000, capture: false });
55
+ } else client.log('请按提示完成 SSH 验证。Windows 的 OpenSSH 不支持连接复用,可能需要输入两次密码。');
56
+ const inspect = await run(login([config.host, 'sh -c ' + quote(sshPolicyScript({ enable: enablePubkey }))]),
57
+ { capture: true, ...prompted });
49
58
  const policy = parseSSHPolicy(inspect.stdout);
50
59
  if (policy.backup) client.log('已开启公钥登录。配置备份:' + policy.backup);
51
60
  if (policy.pubkeyauthentication === 'no')
@@ -76,7 +85,7 @@ export async function ensureSSH(client, config, { enablePubkey = false } = {}) {
76
85
  { env: client.env, capture: true, timeout: 30000 });
77
86
  const publicKey = derived.stdout.trim();
78
87
  const script = authorizeKeyScript(publicKey);
79
- await run(['-S', socket, config.host, 'sh -c ' + quote(script)], { capture: false });
88
+ await run(login([config.host, 'sh -c ' + quote(script)]), { capture: false, ...(multiplex ? {} : { timeout: 180000 }) });
80
89
  if (!await probe(['-i', key, '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none']))
81
90
  throw new Error('公钥已添加,但独立密钥登录验证失败。请检查服务器的公钥登录策略和 authorized_keys 设置;未修改本机 SSH 配置。');
82
91
 
@@ -84,7 +93,9 @@ export async function ensureSSH(client, config, { enablePubkey = false } = {}) {
84
93
  // IdentityFile is additive; do not replace existing host/user/proxy rules.
85
94
  const configFile = path.join(client.home, '.ssh', 'config');
86
95
  const host = config.host.split('@').at(-1);
87
- const escaped = key.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"');
96
+ // Forward slashes avoid backslash escaping differences between OpenSSH versions on Windows.
97
+ const file = (client.platform ?? process.platform) === 'win32' ? key.replaceAll('\\', '/') : key;
98
+ const escaped = file.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"');
88
99
  const block = `\n# claude-remote-cli ${id}\nHost ${host}\n IdentityFile "${escaped}"\nHost *\n`;
89
100
  const previous = await fs.readFile(configFile, 'utf8').catch(error => {
90
101
  if (error.code === 'ENOENT') return ''; throw error;
@@ -93,8 +104,10 @@ export async function ensureSSH(client, config, { enablePubkey = false } = {}) {
93
104
  if (!await probe()) throw new Error('密钥已授权,但默认 SSH 配置验证失败。请检查 ~/.ssh/config 的 IdentityFile 和 IdentitiesOnly 设置。');
94
105
  client.debug('SSH 免密登录已配置并验证成功。');
95
106
  } finally {
96
- await run(['-S', socket, '-O', 'exit', config.host], { capture: true, check: false, timeout: 5000 }).catch(() => {});
97
- await fs.rm(temporary, { recursive: true, force: true });
107
+ if (multiplex) {
108
+ if (socket) await run(['-S', socket, '-O', 'exit', config.host], { capture: true, check: false, timeout: 5000 }).catch(() => {});
109
+ if (temporary) await fs.rm(temporary, { recursive: true, force: true });
110
+ }
98
111
  }
99
112
  });
100
113
  }
package/src/system.js CHANGED
@@ -14,17 +14,36 @@ export async function exists(file) {
14
14
  catch (error) { if (error.code === 'ENOENT') return false; throw error; }
15
15
  }
16
16
 
17
- export async function which(name, search = process.env.PATH || '') {
18
- for (const directory of search.split(path.delimiter)) {
19
- const file = path.resolve(directory || '.', name);
20
- try {
21
- await fs.access(file, constants.X_OK);
22
- if ((await fs.stat(file)).isFile()) return file;
23
- } catch (error) { if (!['ENOENT', 'EACCES', 'ENOTDIR'].includes(error.code)) throw error; }
17
+ // On Windows, try the same extensions as spawn() does (.com, .exe); there is no
18
+ // execute permission bit, so existence is the check.
19
+ export async function which(name, search = process.env.PATH || '', { platform = process.platform } = {}) {
20
+ const windows = platform === 'win32';
21
+ const names = windows && !path.extname(name) ? [name + '.com', name + '.exe'] : [name];
22
+ for (const entry of search.split(windows ? ';' : path.delimiter)) {
23
+ const directory = windows ? entry.replace(/^"(.*)"$/, '$1') : entry;
24
+ for (const candidate of names) {
25
+ const file = path.resolve(directory || '.', candidate);
26
+ try {
27
+ await fs.access(file, windows ? constants.F_OK : constants.X_OK);
28
+ if ((await fs.stat(file)).isFile()) return file;
29
+ } catch (error) { if (!['ENOENT', 'EACCES', 'ENOTDIR', 'EINVAL'].includes(error.code)) throw error; }
30
+ }
24
31
  }
25
32
  return null;
26
33
  }
27
34
 
35
+ // Windows refuses to replace a file another process (editor, antivirus,
36
+ // indexer) briefly holds open; retry a few times before giving up.
37
+ export async function rename(from, to, { platform = process.platform, attempts = 10 } = {}) {
38
+ for (let attempt = 1; ; attempt++) {
39
+ try { return await fs.rename(from, to); }
40
+ catch (error) {
41
+ if (platform !== 'win32' || attempt >= attempts || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error;
42
+ await new Promise(resolve => setTimeout(resolve, 20 * attempt));
43
+ }
44
+ }
45
+ }
46
+
28
47
  export async function readJSON(file, fallback) {
29
48
  try { return JSON.parse(await fs.readFile(file, 'utf8')); }
30
49
  catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
@@ -35,7 +54,7 @@ export async function writeJSON(file, value) {
35
54
  const temp = file + '.' + randomUUID() + '.tmp';
36
55
  try {
37
56
  await fs.writeFile(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
38
- await fs.rename(temp, file);
57
+ await rename(temp, file);
39
58
  } finally { await fs.rm(temp, { force: true }); }
40
59
  }
41
60
 
@@ -102,16 +121,21 @@ export function installSignalHandlers() {
102
121
  for (const signal of ['SIGHUP', 'SIGTERM']) process.on(signal, () => { terminate(signal); });
103
122
  }
104
123
 
105
- export function execute(command, args = [], { capture = false, timeout = 0, check = true, env = process.env, input } = {}) {
124
+ // `prompt` captures stdout but leaves stdin and stderr on the terminal, so a
125
+ // password or host-key prompt from ssh stays visible and answerable.
126
+ export function execute(command, args = [], { capture = false, timeout = 0, check = true, env = process.env, input, prompt = false } = {}) {
106
127
  return new Promise((resolve, reject) => {
107
- const child = trackChild(spawn(command, args, { stdio: [input !== undefined ? 'pipe' : capture ? 'ignore' : 'inherit',
108
- capture ? 'pipe' : 'inherit', capture ? 'pipe' : 'inherit'], env }));
128
+ const stdio = prompt ? ['inherit', 'pipe', 'inherit'] : [input !== undefined ? 'pipe' : capture ? 'ignore' : 'inherit',
129
+ capture ? 'pipe' : 'inherit', capture ? 'pipe' : 'inherit'];
130
+ const child = trackChild(spawn(command, args, { stdio, env }));
109
131
  if (input !== undefined) {
110
132
  child.stdin.on('error', () => {}); // An early remote failure is reported by the exit status.
111
133
  child.stdin.end(input);
112
134
  }
113
135
  let stdout = '', stderr = '', timedOut = false, overflow = false, killTimer;
114
- const interrupt = () => child.kill('SIGINT');
136
+ // The Windows console already delivers Ctrl+C to the child, and kill()
137
+ // there would terminate it outright instead of interrupting it.
138
+ const interrupt = () => { if (process.platform !== 'win32') child.kill('SIGINT'); };
115
139
  process.on('SIGINT', interrupt);
116
140
  const timer = timeout ? setTimeout(() => {
117
141
  timedOut = true;
@@ -122,12 +146,15 @@ export function execute(command, args = [], { capture = false, timeout = 0, chec
122
146
  clearTimeout(timer); clearTimeout(killTimer);
123
147
  process.off('SIGINT', interrupt);
124
148
  };
125
- if (capture) {
126
- child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
149
+ if (child.stdout) {
150
+ child.stdout.setEncoding('utf8');
127
151
  child.stdout.on('data', chunk => {
128
152
  stdout += chunk;
129
153
  if (stdout.length > 32 * 1024 * 1024) { overflow = true; child.kill('SIGKILL'); }
130
154
  });
155
+ }
156
+ if (child.stderr) {
157
+ child.stderr.setEncoding('utf8');
131
158
  child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-65536); });
132
159
  }
133
160
  child.once('error', error => { cleanup(); reject(new Error(`无法执行 ${command}:${error.message}`)); });