@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 +26 -5
- package/package.json +5 -3
- package/src/cli.js +8 -2
- package/src/client.js +5 -1
- package/src/installer.js +26 -11
- package/src/local/processes.js +37 -7
- package/src/local/server.js +10 -2
- package/src/local/shell.js +72 -0
- package/src/local/tunnel.js +49 -1
- package/src/local/workspace.js +15 -4
- package/src/local-mode.js +9 -4
- package/src/ssh-auth.js +27 -14
- package/src/system.js +64 -15
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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'
|
|
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}`,
|
|
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('
|
|
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
|
|
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
|
-
|
|
74
|
-
await
|
|
75
|
-
await
|
|
76
|
-
|
|
77
|
-
await
|
|
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
|
});
|
package/src/local/processes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import
|
|
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
|
|
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
|
|
53
|
-
const
|
|
54
|
-
|
|
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)]);
|
package/src/local/server.js
CHANGED
|
@@ -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
|
-
() =>
|
|
64
|
-
|
|
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
|
+
}
|
package/src/local/tunnel.js
CHANGED
|
@@ -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
|
-
|
|
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-'));
|
package/src/local/workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
'
|
|
47
|
-
|
|
48
|
-
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
86
|
-
capture ? 'pipe' : 'inherit', capture ? 'pipe' : 'inherit']
|
|
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
|
-
|
|
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 (
|
|
104
|
-
child.stdout.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}`)); });
|