@znmin/claude-remote-cli 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,15 +25,33 @@ 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.6.tgz
28
+ npm install -g ./znmin-claude-remote-cli-0.3.0.tgz
29
29
  ```
30
30
 
31
- 若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.2.6.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
31
+ 若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.3.0.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
+ ### 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
+
37
55
  如果用户已经安装其他提供 `claude` 命令的软件,应先卸载旧客户端,避免 npm 命令冲突或 PATH 指向旧程序。无需使用 `--force` 覆盖。
38
56
 
39
57
  ## 开始使用
@@ -131,6 +149,7 @@ claude-remote configure my-server --mode local
131
149
  - 浏览器元素选择器在多次快照间保持不变;快照包含 iframe(选择器形如 `frame=1 >> …`)和开放的 Shadow DOM 元素;截图支持整页和单个元素。
132
150
  - 本地项目的 `CLAUDE.md`、`.claude/CLAUDE.md`、`CLAUDE.local.md` 在启动时读取并放入系统提示(合计最多 64 KiB),不再依赖模型自行读取。
133
151
  - 关闭终端窗口(SIGHUP)或被 `kill`(SIGTERM)时也会清理浏览器、后台命令、SSH 隧道和服务器临时文件。
152
+ - 0.2.7 起,SSH 断线等原因导致远程 Claude 异常结束时,会自动关闭它遗留的鼠标上报、焦点事件、括号粘贴和扩展键盘等终端模式,避免移动鼠标或按键时输出乱码。若仍显示异常(例如停留在全屏界面),可运行 `reset`。
134
153
  - 启动时浏览器检查与 SSH 连接并行,浏览器已就绪时不再调用 Playwright 安装程序;服务器上的版本检查和配置写入合并为一次往返。
135
154
 
136
155
  Git、npm、pnpm、Python、Go、Docker、数据库 CLI、`gh` 等共用本地终端,不需要逐项适配,仍需在本机安装并具备必要的登录、连接配置及权限。终端继承启动用户环境和登录 shell,默认工作目录为当前项目。长任务使用后台进程,再查询日志和停止;退出会话会停止仍受本工具管理的进程组,自行 daemonize 或逃离原进程组的程序不在自动清理保证内。
@@ -219,6 +238,7 @@ CLAUDE_REMOTE_DEBUG=1 claude
219
238
  | `CLAUDE_REMOTE_PREPARE_SYNC=1` | npm 安装阶段提前准备同步工具,默认按需准备 |
220
239
  | `CLAUDE_REMOTE_SKIP_DOWNLOAD=1` | 跳过安装阶段的同步工具准备 |
221
240
  | `CLAUDE_REMOTE_DEBUG=1` | 显示详细启动过程和错误堆栈 |
241
+ | `CLAUDE_REMOTE_SHELL` | 仅 Windows:本地终端使用的 `bash.exe` 或 `pwsh.exe`/`powershell.exe` 完整路径 |
222
242
 
223
243
  `npm install --ignore-scripts` 也受支持,首次使用各模式时准备对应组件。无法访问 GitHub 时可自行准备 Mutagen 并使用 `CLAUDE_REMOTE_MUTAGEN`。浏览器缓存由 Playwright 管理,支持其 `PLAYWRIGHT_BROWSERS_PATH` 环境变量。
224
244
 
@@ -230,12 +250,13 @@ CLAUDE_REMOTE_DEBUG=1 claude
230
250
  npm ci --ignore-scripts
231
251
  npm run lint
232
252
  npm test
253
+ npm run test:windows # Windows 上运行的客户端测试(CI 在 windows-latest 运行)
233
254
 
234
255
  # 可选:真实本地同步与受控浏览器测试
235
256
  CLAUDE_REMOTE_INTEGRATION=1 CLAUDE_REMOTE_BROWSER_TEST=1 npm test
236
257
 
237
258
  npm pack
238
- npm install -g ./znmin-claude-remote-cli-0.2.6.tgz
259
+ npm install -g ./znmin-claude-remote-cli-0.3.0.tgz
239
260
 
240
261
  # 确定包名、版本和使用条款后,使用有发布权限的 npm 账号发布
241
262
  npm login
@@ -244,6 +265,6 @@ npm publish --access public
244
265
 
245
266
  GitHub 仓库名为 `claude-remote-cli`,npm 包名为 `@znmin/claude-remote-cli`,命令名为 `claude` 和 `claude-remote`。发布账号必须拥有 npm 用户作用域 `@znmin`,或拥有同名 npm 组织的发布权限;GitHub 身份不会自动授予 npm 权限。当前 `license` 为 `UNLICENSED`,尚未指定开源许可证。
246
267
 
247
- 测试覆盖真实 MCP HTTP 调用、本机文件与进程、受控浏览器交互、本地真实同步、模拟 SSH/Claude 的完整启动清理链路,以及官方安装流程的隔离模拟。真实服务器完整启动、Claude 模型端到端调用、Linux 和其他 CPU 架构尚未完成实测。
268
+ 测试覆盖真实 MCP HTTP 调用、本机文件与进程、受控浏览器交互、本地真实同步、模拟 SSH/Claude 的完整启动清理链路,以及官方安装流程的隔离模拟。真实服务器完整启动、Claude 模型端到端调用、Linux 和其他 CPU 架构尚未完成实测。Windows 客户端的命令构造、隧道端口解析和免密配置流程在 macOS 上以模拟方式测试,真实 Windows 终端测试在 CI 的 windows-latest 运行;尚未在真实 Windows 电脑上连接服务器实测。
248
269
 
249
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.6",
4
- "description": "Run Claude Code on an SSH server with local filesystem, terminal and browser tools, or remote bidirectional sync",
3
+ "version": "0.3.0",
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,13 @@ 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
+ }
125
131
  try {
126
132
  const { browserReady } = await import('./local/browser.js');
127
133
  const { chromium } = await import('playwright');
package/src/client.js CHANGED
@@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
- import { execute, exists, quote, readJSON, shellJoin, which, withLock, writeJSON } from './system.js';
5
+ import { execute, exists, quote, readJSON, restoreTerminal, shellJoin, which, withLock, writeJSON } from './system.js';
6
6
  import { ensureMutagen } from './installer.js';
7
7
  import { ensureRemoteClaude, remoteEnvironment as prefix } from './remote-claude.js';
8
8
  import { sshArgs } from './ssh.js';
@@ -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');
@@ -67,6 +68,7 @@ export class Client {
67
68
  if (this.env.CLAUDE_REMOTE_DEBUG === '1') this.log(...messages);
68
69
  };
69
70
  this.tty = options.tty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
71
+ this.restoreTerminal = options.restoreTerminal || (() => restoreTerminal());
70
72
  }
71
73
 
72
74
  async configure(input) {
@@ -204,6 +206,8 @@ export class Client {
204
206
  try {
205
207
  code = (await this.remote(config, prefix + 'cd -- ' + remotePath(item.remote) +
206
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();
207
211
  } finally {
208
212
  try { await this.flush(item.name); }
209
213
  catch (error) {
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.
@@ -169,7 +171,10 @@ export async function launchLocal(client, config, extra, dependencies = {}) {
169
171
  const script = remoteEnvironment + 'cd -- ' + remotePath(remoteDirectory) + ' || exit 1; ' +
170
172
  'trap ' + quote('rm -f -- ' + remotePath(configFile) + ' ' + remotePath(promptFile)) + ' EXIT; ' +
171
173
  shellJoin([config.claude, ...args]);
172
- return (await remote(script, { interactive: true, check: false })).code;
174
+ const code = (await remote(script, { interactive: true, check: false })).code;
175
+ // A dropped connection leaves the remote UI's terminal modes switched on.
176
+ if (code !== 0) client.restoreTerminal();
177
+ return code;
173
178
  } finally {
174
179
  removeCleanup();
175
180
  await cleanup();
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
 
@@ -57,7 +76,7 @@ export function onCleanup(callback) {
57
76
  return () => cleanups.delete(callback);
58
77
  }
59
78
 
60
- export function terminate(signal, { exit = code => process.exit(code), graceMs = 10000 } = {}) {
79
+ export function terminate(signal, { exit = code => process.exit(code), graceMs = 10000, terminal = process.stdout } = {}) {
61
80
  if (terminating) return terminating;
62
81
  const code = 128 + (os.constants.signals[signal] || 15);
63
82
  terminating = (async () => {
@@ -71,25 +90,52 @@ export function terminate(signal, { exit = code => process.exit(code), graceMs =
71
90
  for (const child of trackedChildren) {
72
91
  try { child.kill('SIGTERM'); } catch { /* already gone */ }
73
92
  }
93
+ restoreTerminal(terminal);
74
94
  exit(code);
75
95
  })();
76
96
  return terminating;
77
97
  }
78
98
 
99
+ // A remote full-screen program that loses its connection cannot switch off the
100
+ // terminal modes it enabled, so mouse moves, focus changes and keys show up as
101
+ // stray escape codes. Every sequence here only turns a mode off and is a no-op
102
+ // when the mode is already off. The alternate screen (?1049l) is deliberately
103
+ // left alone: leaving it when not active can move the cursor on some terminals.
104
+ export const TERMINAL_RESET = [
105
+ '\x1b[?1000l', '\x1b[?1002l', '\x1b[?1003l', '\x1b[?1005l', '\x1b[?1006l', '\x1b[?1015l', // mouse reporting
106
+ '\x1b[?1004l', // focus in/out events
107
+ '\x1b[?2004l', // bracketed paste
108
+ '\x1b[=0;1u', '\x1b[<99u', // kitty keyboard protocol: clear flags, pop pushed levels
109
+ '\x1b[>4m', // xterm modifyOtherKeys
110
+ '\x1b[?1l', '\x1b>', // normal cursor keys and keypad
111
+ '\x1b[0m', '\x1b[?25h' // plain text attributes, visible cursor
112
+ ].join('');
113
+
114
+ export function restoreTerminal(stream = process.stdout) {
115
+ if (!stream?.isTTY) return false;
116
+ try { stream.write(TERMINAL_RESET); return true; }
117
+ catch { return false; } // The terminal may already be gone (SIGHUP).
118
+ }
119
+
79
120
  export function installSignalHandlers() {
80
121
  for (const signal of ['SIGHUP', 'SIGTERM']) process.on(signal, () => { terminate(signal); });
81
122
  }
82
123
 
83
- 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 } = {}) {
84
127
  return new Promise((resolve, reject) => {
85
- const child = trackChild(spawn(command, args, { stdio: [input !== undefined ? 'pipe' : capture ? 'ignore' : 'inherit',
86
- 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 }));
87
131
  if (input !== undefined) {
88
132
  child.stdin.on('error', () => {}); // An early remote failure is reported by the exit status.
89
133
  child.stdin.end(input);
90
134
  }
91
135
  let stdout = '', stderr = '', timedOut = false, overflow = false, killTimer;
92
- 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'); };
93
139
  process.on('SIGINT', interrupt);
94
140
  const timer = timeout ? setTimeout(() => {
95
141
  timedOut = true;
@@ -100,12 +146,15 @@ export function execute(command, args = [], { capture = false, timeout = 0, chec
100
146
  clearTimeout(timer); clearTimeout(killTimer);
101
147
  process.off('SIGINT', interrupt);
102
148
  };
103
- if (capture) {
104
- child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
149
+ if (child.stdout) {
150
+ child.stdout.setEncoding('utf8');
105
151
  child.stdout.on('data', chunk => {
106
152
  stdout += chunk;
107
153
  if (stdout.length > 32 * 1024 * 1024) { overflow = true; child.kill('SIGKILL'); }
108
154
  });
155
+ }
156
+ if (child.stderr) {
157
+ child.stderr.setEncoding('utf8');
109
158
  child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-65536); });
110
159
  }
111
160
  child.once('error', error => { cleanup(); reject(new Error(`无法执行 ${command}:${error.message}`)); });